Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f97b8a4576
|
||
|
|
325a86f247 | ||
|
|
434fe801ba
|
||
|
|
84378e856b | ||
|
|
a2cece750b |
@@ -11,6 +11,13 @@ When a `v*` tag is pushed, `.gitea/workflows/release.yml` publishes the matching
|
||||
the plugin to the next patch version and adds a fresh section here for it. Record
|
||||
each change under the current top section as you work.
|
||||
|
||||
## [1.5.0]
|
||||
|
||||
### Added
|
||||
- **A registration question can now be asked of students only, and can be required of a student without being required of the account holder.** Every account-signup question was asked of everybody who registered, on the same terms — so "School and grade" had to be put to the adult signing themselves up, and a question a studio needed answered for a child could only be made required by demanding it of everyone. Each question now says who it is asked of — everyone, or only the students you register on behalf of — and carries its own **Required** setting for each: optional for you, required for every student you enrol, is now a thing a studio can ask for. Existing questions are untouched: they stay asked of everyone, and one that was required stays required of everyone.
|
||||
- **You can now edit your own details on the profile page**, not just your students'. The page is called **Your profile**, and until now the one person on it you could not change was yourself: a mistyped name at signup, or a name that had since changed, meant asking the studio to fix it. **Your details** now sits at the top of the page with your name, your birth year, and whether you take lessons yourself. Your email address is shown but not editable — it is also how you sign in, so changing it stays a studio-side job.
|
||||
- **"I take lessons myself" can be corrected after signup.** Signup asks whether you are registering just yourself, only on behalf of students, or both, and the answer decides whether you are offered as a student when booking. Choosing wrongly — or taking up lessons later alongside the children you book for — used to leave you asking the studio to change it. Ticking the box makes you bookable again and asks for your birth year like any other student; unticking it takes you back off the list without discarding the birth year you already gave, so ticking it back on costs you nothing.
|
||||
|
||||
## [1.4.1]
|
||||
|
||||
### Added
|
||||
|
||||
@@ -521,6 +521,32 @@
|
||||
}
|
||||
|
||||
/* The guardian's manage-children screen ([us_family]). */
|
||||
|
||||
/*
|
||||
* The account holder's own details, set off from the students below so the two
|
||||
* halves of the page do not read as one long form.
|
||||
*/
|
||||
.us-family-self {
|
||||
margin-bottom: 24px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.us-family-self-email span {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/*
|
||||
* Guidance under a control, not a label: it explains when a field matters
|
||||
* rather than naming it, so it is sized down and reads after the input.
|
||||
*/
|
||||
.us-field-hint {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
font-size: 0.9em;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.us-family-list {
|
||||
margin: 0 0 20px;
|
||||
padding: 0;
|
||||
|
||||
@@ -187,7 +187,8 @@ than writing `0`, so "not set" stays the one spelling of "yes, a student".
|
||||
|
||||
One guard: a guardian-only account with **nobody linked to it** is still offered
|
||||
itself, because an empty picker is no way to book at all. They can put the
|
||||
account right from the profile page.
|
||||
account right from the profile page — see **Your details** below, where the flag
|
||||
is editable as "I take lessons myself".
|
||||
|
||||
Per child the form collects:
|
||||
- **Name** (required)
|
||||
@@ -267,9 +268,51 @@ Booking-scope policies are accepted at booking time by whoever is signed in;
|
||||
`BookingEndpoint` passes the same `accepted_by` when a guardian books for a
|
||||
child.
|
||||
|
||||
## Managing children
|
||||
## Managing the account
|
||||
|
||||
`[us_family]` (block: **Profile**) renders the guardian's manage-children screen:
|
||||
### Your details
|
||||
|
||||
The profile screen opens with the account holder's own record, because the
|
||||
alternative was a page called **Your profile** on which the one person who could
|
||||
not be edited was you. The form saves through
|
||||
`GuardianService::updateSelf()` and holds:
|
||||
|
||||
- **Your name** — `display_name` and `nickname`, written together for the reason
|
||||
`updateChild()` does: `UserName` reads the nickname first, and leaving it
|
||||
behind would put the account's email address back on every screen that names a
|
||||
person.
|
||||
- **I take lessons myself** — the positive of `us_guardian_only`, so the form
|
||||
asks the question the way a person answers it and `updateSelf()` is the one
|
||||
place the sense is flipped. This is what makes good on "they can put the
|
||||
account right from the profile page": an account that registered as a pure
|
||||
guardian and later took up lessons — or ticked the wrong radio at signup — can
|
||||
now correct itself instead of asking the studio to.
|
||||
- **Your birth year** — `us_birth_year`, the same meta and the same
|
||||
`normaliseBirthYear()` rule every student is held to.
|
||||
|
||||
Two decisions worth keeping:
|
||||
|
||||
- The birth-year field carries **no `required` attribute**. It is asked of a
|
||||
student only, and the family screen loads no JavaScript, so a browser-enforced
|
||||
`required` would leave a guardian who books solely for other people unable to
|
||||
submit the form at all. `FamilyPage::handleSelf()` enforces it against the
|
||||
checkbox instead, which is where the condition actually lives.
|
||||
- Unticking **I take lessons myself** does **not** clear a stored birth year.
|
||||
The box says who books, not "forget what you know about me", and someone who
|
||||
ticks it back on the next visit should find their details as they left them.
|
||||
|
||||
The **email** is shown but not editable: it is the account's `user_login` as
|
||||
well as its address, so changing it is a studio-side job rather than a
|
||||
profile-screen one.
|
||||
|
||||
The account-scope questions are not re-asked here, in either direction — the
|
||||
child rows do not offer them on edit either, and a studio that needs a newly
|
||||
self-declared student's answers asks for them the same way it would for any
|
||||
other change of circumstance.
|
||||
|
||||
### Managing children
|
||||
|
||||
The rest of `[us_family]` (block: **Profile**) is the manage-children screen:
|
||||
list the children, add one, edit a name/birth year, remove one.
|
||||
|
||||
- **Add** creates another accountless child user and links it. Account-scope
|
||||
@@ -366,7 +409,8 @@ need — via `GuardianService::contactFor()`.
|
||||
- Models: `Unsupervised\Schedular\Guardian\GuardianLink`
|
||||
- Repository: `Unsupervised\Schedular\Guardian\GuardianRepository`
|
||||
- Service: `Unsupervised\Schedular\Guardian\GuardianService` (child creation,
|
||||
`canActFor()`, `payerFor()`, `contactFor()`, removal rules)
|
||||
`canActFor()`, `payerFor()`, `contactFor()`, removal rules, and the account
|
||||
holder's own record via `accountHolder()`/`updateSelf()`)
|
||||
- Login block: `Unsupervised\Schedular\Guardian\ChildLoginGate`
|
||||
- Frontend: `Unsupervised\Schedular\Guardian\FamilyPage` (`[us_family]`)
|
||||
- Shared question field: `Unsupervised\Schedular\Registration\QuestionField`
|
||||
|
||||
@@ -23,11 +23,32 @@ and the same authoring page (**Offerings → Questions**).
|
||||
| `label` | VARCHAR(255) | The question text shown to the registrant |
|
||||
| `field_type` | VARCHAR(20) | `text` / `textarea` / `select` / `checkbox` |
|
||||
| `options` | TEXT | JSON array of choices (for `select`); NULL otherwise |
|
||||
| `is_required` | TINYINT(1) | 1 = registrant must answer to continue |
|
||||
| `audience` | VARCHAR(20) | `all` (default) or `child` — who the question is asked of (account scope) |
|
||||
| `is_required` | TINYINT(1) | 1 = the **account holder** must answer to continue |
|
||||
| `is_required_child` | TINYINT(1) | 1 = each **student being registered** must answer to continue |
|
||||
| `sort_order` | INT | Display order within the scope |
|
||||
| `is_active` | TINYINT(1) | 0 = retired, 1 = shown on the form |
|
||||
| `created_at` | DATETIME | Insertion time |
|
||||
|
||||
## Audience and Required-ness (account scope)
|
||||
An account-scope question is asked in two places, and the two are configured separately:
|
||||
|
||||
- **The account holder's own "About you" panel** — shown when they are registering
|
||||
themselves (`self` or `both`). Governed by `audience` (a `child` question is not asked
|
||||
here at all) and by `is_required`.
|
||||
- **Each student block** — one per person they are registering on behalf of, on the signup
|
||||
form and on the guardian's family screen. Every question is asked here regardless of
|
||||
`audience`; `is_required_child` decides whether it blocks submission.
|
||||
|
||||
That split is what lets a studio ask "School and grade" of children only, or make
|
||||
"Previous experience" optional for an adult signing themselves up but required for every
|
||||
child they enrol. `audience = 'child'` leaves `is_required` moot — the question never
|
||||
reaches the account holder's panel.
|
||||
|
||||
`audience` and `is_required_child` are ignored for offering-scope questions: booking and
|
||||
enrolment ask their intake questions once, about the student being booked, with no separate
|
||||
account-holder form to differ from.
|
||||
|
||||
## Data Model — `{prefix}us_question_answers`
|
||||
|
||||
| Column | Type | Notes |
|
||||
@@ -51,19 +72,24 @@ lesson, a group enrolment, or an account signup (`account` + the user ID).
|
||||
|
||||
## Account-scope Flow (signup)
|
||||
1. The `[us_student_register]` page (`Auth\RegistrationPage`) loads active account-scope questions via `QuestionRepository::findByScope('account')`.
|
||||
2. The form is a single page. The questions sit in an **About you** panel, alongside the account holder's birth year, between the "Who are you registering?" choice and the students being added. `assets/js/register.js` disables and hides that whole panel when the choice is "on behalf of students" — the questions describe a student and a pure guardian is not one — and puts the same questions in every child block instead. Progressive enhancement: without JS every panel shows and the single submit still works. This applies to **every** signup path (invite, group link, self-approval).
|
||||
3. On submit, required answers are validated **before** the user is created (a missing answer returns an error and creates no account); after creation each answered question is written to `us_question_answers` with `registration_type = 'account'`, `registration_id = student_id = <new user ID>`.
|
||||
2. The form is a single page. The questions sit in an **About you** panel, alongside the account holder's birth year, between the "Who are you registering?" choice and the students being added — minus any `audience = 'child'` question, which is never asked of the account holder. `assets/js/register.js` disables and hides that whole panel when the choice is "on behalf of students" — the questions describe a student and a pure guardian is not one — and puts the full question set in every child block instead. Progressive enhancement: without JS every panel shows and the single submit still works. This applies to **every** signup path (invite, group link, self-approval).
|
||||
3. On submit, required answers are validated **before** the user is created (a missing answer returns an error and creates no account) — `is_required` against the account holder's panel, `is_required_child` against each student block; after creation each answered question is written to `us_question_answers` with `registration_type = 'account'`, `registration_id = student_id = <new user ID>`. An answer posted for a `child`-audience question against the account holder is discarded, not stored.
|
||||
4. A studio admin reviews the answers on the student's admin screen under **Registration Information** (`Auth\StudentHistory::registrationInfo()` lists every account question paired with the student's answer, "—" when unanswered). These rows are excluded from the offering-scope "Intake answers" table.
|
||||
|
||||
## Admin Interface
|
||||
Both scopes are edited from **Offerings → Questions** (`Registration\QuestionController`):
|
||||
- Pick an offering to edit its questions, or **"Account signup (all registrations)"** for the account-scope questions.
|
||||
- The account-scope form adds **Asked of** (everyone / students only) and a second **Required** checkbox for students; both are hidden for offering scope, where they have no meaning.
|
||||
- Studio admin (`manage_questions` + `manage_instructors`) edits any offering's questions and the account-scope questions.
|
||||
- Instructor (`manage_questions`) edits questions only on their own offerings; the account-scope option is hidden.
|
||||
|
||||
## REST API
|
||||
Only offering-scope questions are exposed over REST. Account-scope questions are managed
|
||||
through the server-rendered admin page and read directly by `RegistrationPage`.
|
||||
through the server-rendered admin page and read directly by `RegistrationPage` — a request
|
||||
naming one is turned away as not found, since the owner check has no offering to check
|
||||
against, so REST can neither read nor overwrite an `audience`. An offering question written
|
||||
over REST mirrors its single `is_required` into `is_required_child`, as the admin form and
|
||||
the upgrade backfill both do.
|
||||
|
||||
| Method | Endpoint | Permission |
|
||||
|----------|---------------------------------------------------|----------------------|
|
||||
@@ -74,12 +100,13 @@ through the server-rendered admin page and read directly by `RegistrationPage`.
|
||||
|
||||
## Implementation
|
||||
- Repositories: `Unsupervised\Schedular\Registration\QuestionRepository` (`findByOffering`, `findByScope`), `Unsupervised\Schedular\Registration\AnswerRepository`
|
||||
- Models: `Unsupervised\Schedular\Registration\Question` (`scope`, nullable `offeringId`), `Unsupervised\Schedular\Registration\Answer` (`REG_ACCOUNT`)
|
||||
- Models: `Unsupervised\Schedular\Registration\Question` (`scope`, nullable `offeringId`, `audience`, `isRequiredChild`, and the `askedOfSelf()` / `isRequiredForSelf()` / `isRequiredForChild()` readers every caller uses instead of touching `isRequired` directly), `Unsupervised\Schedular\Registration\Answer` (`REG_ACCOUNT`)
|
||||
- Admin controller: `Unsupervised\Schedular\Registration\QuestionController`
|
||||
- REST endpoint: `Unsupervised\Schedular\Registration\QuestionEndpoint` (offering scope only)
|
||||
- Signup form: `Unsupervised\Schedular\Auth\RegistrationPage`, `templates/frontend/register-page.php`, `assets/js/register.js`
|
||||
- Admin review: `Unsupervised\Schedular\Auth\StudentHistory::registrationInfo()`, `templates/admin/student-detail.php`
|
||||
- Schema: `us_questions.scope` + nullable `us_questions.offering_id` (requires a plugin version bump so `dbDelta` runs)
|
||||
- Schema: `us_questions.scope` + nullable `us_questions.offering_id`, `us_questions.audience`, `us_questions.is_required_child` (each requires a plugin version bump so `dbDelta` runs)
|
||||
- Required-for-students backfill: `is_required_child` arrives with `DEFAULT 0`, which would quietly make every existing required question optional for students. `QuestionRepository::backfillChildRequired()` copies `is_required` into it once; `Plugin::boot()` runs it guarded by the `us_questions_child_required_backfilled` option, after the version gate has let `dbDelta` add the column
|
||||
- Nullability repair: `dbDelta` does **not** reliably relax a column from `NOT NULL` to `NULL`, so sites created before account-scope questions kept `offering_id NOT NULL` and rejected account inserts. `QuestionRepository::ensureOfferingNullable()` re-applies the nullable definition (idempotent `ALTER … MODIFY`); `Plugin::boot()` runs it once, guarded by the `us_questions_offering_nullable` option rather than the version gate (affected sites may already be on the current version)
|
||||
|
||||
## Tests
|
||||
@@ -87,8 +114,10 @@ through the server-rendered admin page and read directly by `RegistrationPage`.
|
||||
- `tests/Unit/Registration/AnswerRepositoryTest.php`
|
||||
- `tests/Unit/Registration/QuestionTest.php`
|
||||
- `tests/Unit/Registration/AnswerTest.php`
|
||||
- `tests/Unit/Registration/QuestionFieldTest.php`
|
||||
- `tests/Unit/Auth/RegistrationPageTest.php`
|
||||
- `tests/Unit/Auth/StudentHistoryTest.php`
|
||||
- `tests/Unit/Guardian/FamilyPageTest.php`
|
||||
|
||||
## Per-Child Answers
|
||||
For a parent/guardian signup, **account-scope** questions are asked **once per
|
||||
@@ -96,5 +125,5 @@ child** rather than once per guardian — in practice they describe the student
|
||||
(instrument, level, school), not the account holder. Each answer's `student_id`
|
||||
and `registration_id` are the child's user ID, so a studio admin reading a
|
||||
child's screen sees the information that describes them. The guardian's family
|
||||
screen asks the same questions when a child is added later. See
|
||||
`parent-guardian-accounts.md`.
|
||||
screen asks the same questions when a child is added later, under the same
|
||||
`is_required_child` rule as the signup form. See `parent-guardian-accounts.md`.
|
||||
|
||||
@@ -319,6 +319,13 @@ class RegistrationPage {
|
||||
// student themselves. "Both" is both.
|
||||
$registeringFor = $this->submittedRegisteringFor();
|
||||
|
||||
// A "students only" question is never put to the account holder, so it is
|
||||
// dropped before their answers are validated or stored — a crafted post
|
||||
// cannot file one against them.
|
||||
$selfQuestions = array_values(
|
||||
array_filter( $accountQuestions, static fn( Question $question ): bool => $question->askedOfSelf() )
|
||||
);
|
||||
|
||||
// "Students" and "both" collect student blocks; only "self" does not.
|
||||
$isGuardian = self::FOR_SELF !== $registeringFor;
|
||||
|
||||
@@ -355,7 +362,7 @@ class RegistrationPage {
|
||||
// Checked as two passes rather than one so the message can say *whose*
|
||||
// answers are missing — under "both" a single message could not.
|
||||
foreach ( array_column( $children, 'answers' ) as $set ) {
|
||||
if ( $this->hasUnansweredRequired( $accountQuestions, $set ) ) {
|
||||
if ( $this->hasUnansweredRequired( $accountQuestions, $set, forChild: true ) ) {
|
||||
return esc_html__( 'Please answer all required registration questions for each student.', 'unsupervised-schedular' );
|
||||
}
|
||||
}
|
||||
@@ -369,7 +376,7 @@ class RegistrationPage {
|
||||
return esc_html( GuardianService::ownBirthYearError() );
|
||||
}
|
||||
|
||||
if ( $asksSelf && $this->hasUnansweredRequired( $accountQuestions, $answers ) ) {
|
||||
if ( $asksSelf && $this->hasUnansweredRequired( $selfQuestions, $answers ) ) {
|
||||
return esc_html__( 'Please answer all required registration questions.', 'unsupervised-schedular' );
|
||||
}
|
||||
|
||||
@@ -420,7 +427,7 @@ class RegistrationPage {
|
||||
// After the children, so a rollback that deletes this account cannot
|
||||
// leave its answers behind pointing at a user that no longer exists.
|
||||
if ( $asksSelf ) {
|
||||
$this->recordAnswers( $accountQuestions, $answers, (int) $userId );
|
||||
$this->recordAnswers( $selfQuestions, $answers, (int) $userId );
|
||||
}
|
||||
|
||||
if ( $inviteValid && ! $invite->isGroup() ) {
|
||||
@@ -564,12 +571,18 @@ class RegistrationPage {
|
||||
/**
|
||||
* Whether any required question in `$questions` is left blank in `$answers`.
|
||||
*
|
||||
* `$forChild` picks which required-ness applies: a question can be optional
|
||||
* for the account holder answering about themselves and still required of
|
||||
* every student they register.
|
||||
*
|
||||
* @param list<Question> $questions
|
||||
* @param array<int, string> $answers
|
||||
*/
|
||||
private function hasUnansweredRequired( array $questions, array $answers ): bool {
|
||||
private function hasUnansweredRequired( array $questions, array $answers, bool $forChild = false ): bool {
|
||||
foreach ( $questions as $question ) {
|
||||
if ( $question->isRequired && '' === trim( (string) ( $answers[ (int) $question->id ] ?? '' ) ) ) {
|
||||
$required = $forChild ? $question->isRequiredForChild() : $question->isRequiredForSelf();
|
||||
|
||||
if ( $required && '' === trim( (string) ( $answers[ (int) $question->id ] ?? '' ) ) ) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
+23
-4
@@ -176,10 +176,26 @@ class BlockPreview {
|
||||
}
|
||||
|
||||
/**
|
||||
* Sample family (manage-children) page: two representative children and the
|
||||
* add form, with the controls inert so the editor preview cannot post.
|
||||
* Sample family page: the account holder's own details, two representative
|
||||
* children and the add form, with the controls inert so the editor preview
|
||||
* cannot post.
|
||||
*/
|
||||
public static function family(): string {
|
||||
$self = sprintf(
|
||||
'<h4>%s</h4><p class="us-family-self-email">%s <span>[email protected]</span></p>'
|
||||
. '<p><label for="us-own-name">%s' . self::REQUIRED_MARK . '</label><input type="text" id="us-own-name" value="%s"></p>'
|
||||
. '<p><label><input type="checkbox" checked disabled> %s</label></p>'
|
||||
. '<p><label for="us-own-birth-year">%s</label><input type="number" id="us-own-birth-year" placeholder="YYYY"></p>'
|
||||
. '<p><button type="button" disabled>%s</button></p>',
|
||||
esc_html__( 'Your details', 'unsupervised-schedular' ),
|
||||
esc_html__( 'Email', 'unsupervised-schedular' ),
|
||||
esc_html__( 'Your name', 'unsupervised-schedular' ),
|
||||
esc_attr__( 'Grace Hopper', 'unsupervised-schedular' ),
|
||||
esc_html__( 'I take lessons myself', 'unsupervised-schedular' ),
|
||||
esc_html__( 'Your birth year', 'unsupervised-schedular' ),
|
||||
esc_html__( 'Save my details', 'unsupervised-schedular' )
|
||||
);
|
||||
|
||||
$children = '';
|
||||
foreach ( [ 'Ada Lovelace', 'Alan Turing' ] as $name ) {
|
||||
$children .= sprintf(
|
||||
@@ -202,9 +218,12 @@ class BlockPreview {
|
||||
);
|
||||
|
||||
return sprintf(
|
||||
'<div class="us-family">%s<h3>%s</h3><ul class="us-family-list">%s</ul><form class="us-family-add">%s</form></div>',
|
||||
self::note( __( 'Editor preview — signed-in guardians see and manage their own students here.', 'unsupervised-schedular' ) ),
|
||||
'<div class="us-family">%s<h3>%s</h3><form class="us-family-self">%s</form>'
|
||||
. '<h4>%s</h4><ul class="us-family-list">%s</ul><form class="us-family-add">%s</form></div>',
|
||||
self::note( __( 'Editor preview — signed-in visitors see and manage their own details and students here.', 'unsupervised-schedular' ) ),
|
||||
esc_html__( 'Your profile', 'unsupervised-schedular' ),
|
||||
$self,
|
||||
esc_html__( 'Your students', 'unsupervised-schedular' ),
|
||||
$children,
|
||||
$add
|
||||
);
|
||||
|
||||
@@ -10,8 +10,8 @@ use Unsupervised\Schedular\Registration\QuestionRepository;
|
||||
use Unsupervised\Schedular\Val;
|
||||
|
||||
/**
|
||||
* The guardian's "my family" screen (`[us_family]`): list, add, edit and remove
|
||||
* the children they book for.
|
||||
* The guardian's "my family" screen (`[us_family]`): their own details, plus
|
||||
* list, add, edit and remove the children they book for.
|
||||
*
|
||||
* Submissions are processed on `template_redirect` — before any output — and
|
||||
* post/redirect/get back to the page, so a refresh cannot resubmit and add the
|
||||
@@ -23,6 +23,7 @@ class FamilyPage {
|
||||
private const RESULT_ADDED = 'added';
|
||||
private const RESULT_UPDATED = 'updated';
|
||||
private const RESULT_REMOVED = 'removed';
|
||||
private const RESULT_SELF = 'self';
|
||||
|
||||
/**
|
||||
* Error from the most recent submission processed on `template_redirect`,
|
||||
@@ -58,6 +59,7 @@ class FamilyPage {
|
||||
|
||||
$userId = get_current_user_id();
|
||||
|
||||
$self = $this->guardians->accountHolder( $userId );
|
||||
$children = $this->guardians->children( $userId );
|
||||
$questions = $this->questions->findByScope( Question::SCOPE_ACCOUNT, activeOnly: true );
|
||||
$error = $this->submitError;
|
||||
@@ -99,6 +101,7 @@ class FamilyPage {
|
||||
'add' => $this->handleAdd( $userId ),
|
||||
'edit' => $this->handleEdit( $userId ),
|
||||
'remove' => $this->handleRemove( $userId ),
|
||||
'self' => $this->handleSelf( $userId ),
|
||||
default => new \WP_Error( 'unknown_action', __( 'Unrecognised request.', 'unsupervised-schedular' ) ),
|
||||
};
|
||||
|
||||
@@ -149,6 +152,21 @@ class FamilyPage {
|
||||
return $error ?? self::RESULT_UPDATED;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the account holder's own details. The birth year is only asked of a
|
||||
* student, so it is the checkbox — not the browser — that decides whether one
|
||||
* is required; the field carries no `required` attribute, or a guardian who
|
||||
* books only for other people could never submit the form at all.
|
||||
*/
|
||||
private function handleSelf( int $userId ): string|\WP_Error {
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce checked by the caller.
|
||||
$isStudent = isset( $_POST['is_student'] );
|
||||
|
||||
$error = $this->guardians->updateSelf( $userId, $this->postString( 'own_name' ), $this->postString( 'own_birth_year' ), $isStudent );
|
||||
|
||||
return $error ?? self::RESULT_SELF;
|
||||
}
|
||||
|
||||
private function handleRemove( int $guardianId ): string|\WP_Error {
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce checked by the caller.
|
||||
$childId = absint( Val::int( $_POST['child_id'] ?? 0 ) );
|
||||
@@ -162,12 +180,16 @@ class FamilyPage {
|
||||
* The first required question left unanswered, as the error to show — or null
|
||||
* when every required question has a value.
|
||||
*
|
||||
* This screen only ever adds a student the guardian registers, so the
|
||||
* students' required-ness is the one that applies — the same rule the child
|
||||
* blocks on the signup form are held to.
|
||||
*
|
||||
* @param list<Question> $questions
|
||||
* @param array<int, string> $answers question_id => submitted value
|
||||
*/
|
||||
private function firstMissingAnswer( array $questions, array $answers ): ?\WP_Error {
|
||||
foreach ( $questions as $question ) {
|
||||
if ( $question->isRequired && '' === trim( (string) ( $answers[ (int) $question->id ] ?? '' ) ) ) {
|
||||
if ( $question->isRequiredForChild() && '' === trim( (string) ( $answers[ (int) $question->id ] ?? '' ) ) ) {
|
||||
return new \WP_Error( 'missing_answer', __( 'Please answer all required questions for this student.', 'unsupervised-schedular' ) );
|
||||
}
|
||||
}
|
||||
@@ -240,6 +262,7 @@ class FamilyPage {
|
||||
self::RESULT_ADDED => __( 'Student added.', 'unsupervised-schedular' ),
|
||||
self::RESULT_UPDATED => __( 'Details updated.', 'unsupervised-schedular' ),
|
||||
self::RESULT_REMOVED => __( 'Student removed.', 'unsupervised-schedular' ),
|
||||
self::RESULT_SELF => __( 'Your details have been updated.', 'unsupervised-schedular' ),
|
||||
default => '',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -163,6 +163,52 @@ class GuardianService {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the account holder's own details from the profile screen: their
|
||||
* name, whether they are a student in their own right, and — when they are —
|
||||
* their birth year.
|
||||
*
|
||||
* `$isStudent` is the positive of what {@see META_GUARDIAN_ONLY} stores, so
|
||||
* the form can ask the question the way a person would answer it and this is
|
||||
* the single place the sense is flipped.
|
||||
*
|
||||
* Returns null on success, mirroring {@see updateChild()}.
|
||||
*/
|
||||
public function updateSelf( int $userId, string $name, string $birthYear, bool $isStudent ): ?\WP_Error {
|
||||
$name = trim( $name );
|
||||
if ( '' === $name ) {
|
||||
return new \WP_Error( 'missing_name', __( 'Please give your name.', 'unsupervised-schedular' ) );
|
||||
}
|
||||
|
||||
if ( $isStudent && 0 === self::normaliseBirthYear( $birthYear ) ) {
|
||||
return new \WP_Error( 'missing_birth_year', self::ownBirthYearError() );
|
||||
}
|
||||
|
||||
$result = wp_update_user(
|
||||
[
|
||||
'ID' => $userId,
|
||||
'display_name' => $name,
|
||||
'nickname' => $name,
|
||||
]
|
||||
);
|
||||
|
||||
if ( is_wp_error( $result ) ) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
$this->setGuardianOnly( $userId, ! $isStudent );
|
||||
|
||||
// Only written when they are a student. Saying "I only book for other
|
||||
// people" is a statement about who books, not an instruction to forget a
|
||||
// year already on file — and someone who ticks the box back on the next
|
||||
// visit should find their own details as they left them.
|
||||
if ( $isStudent ) {
|
||||
$this->setBirthYear( $userId, $birthYear );
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unlink a child and delete their account. Refused once the child has any
|
||||
* lesson or enrolment history: their id is referenced by lessons, payments and
|
||||
@@ -319,6 +365,26 @@ class GuardianService {
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The account holder's own details, as the profile screen's form needs them.
|
||||
* The counterpart to {@see children()} for the person reading the page.
|
||||
*
|
||||
* `is_student` is the positive of {@see META_GUARDIAN_ONLY} — see
|
||||
* {@see updateSelf()}, which reads it back the same way round.
|
||||
*
|
||||
* @return array{name: string, email: string, birth_year: string, is_student: bool}
|
||||
*/
|
||||
public function accountHolder( int $userId ): array {
|
||||
$user = get_userdata( $userId );
|
||||
|
||||
return [
|
||||
'name' => UserName::format( $user instanceof \WP_User ? $user : null, $userId ),
|
||||
'email' => $user instanceof \WP_User ? $user->user_email : '',
|
||||
'birth_year' => $this->birthYear( $userId ),
|
||||
'is_student' => ! self::isGuardianOnly( $userId ),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The guardian behind a child, or null when the student books for themselves.
|
||||
*
|
||||
|
||||
@@ -72,6 +72,15 @@ class Plugin {
|
||||
update_option( 'us_questions_offering_nullable', '1' );
|
||||
}
|
||||
|
||||
// One-time backfill of us_questions.is_required_child, which dbDelta adds
|
||||
// defaulting to 0 — leaving every question that *was* required no longer
|
||||
// required of the students a guardian registers. Runs after the version
|
||||
// gate above, so the column it writes to exists. Guarded so a question
|
||||
// later made optional for students stays that way.
|
||||
if ( '1' !== get_option( 'us_questions_child_required_backfilled', '' ) && $questions->backfillChildRequired() ) {
|
||||
update_option( 'us_questions_child_required_backfilled', '1' );
|
||||
}
|
||||
|
||||
$answers = new AnswerRepository( $wpdb );
|
||||
$policies = new PolicyRepository( $wpdb );
|
||||
$policyVersions = new PolicyVersionRepository( $wpdb );
|
||||
|
||||
@@ -21,6 +21,16 @@ class Question {
|
||||
/** Question is studio-wide, asked once at account signup (no offering). */
|
||||
public const SCOPE_ACCOUNT = 'account';
|
||||
|
||||
/** Asked of everyone: the account holder as a student, and each student they register. */
|
||||
public const AUDIENCE_ALL = 'all';
|
||||
|
||||
/**
|
||||
* Asked only of the students someone registers on behalf of — never of the
|
||||
* account holder's own "About you" panel. For the questions that only make
|
||||
* sense about a child ("school and grade", "who may collect them").
|
||||
*/
|
||||
public const AUDIENCE_CHILD = 'child';
|
||||
|
||||
/**
|
||||
* All valid field types.
|
||||
*
|
||||
@@ -43,9 +53,29 @@ class Question {
|
||||
self::SCOPE_ACCOUNT,
|
||||
];
|
||||
|
||||
/**
|
||||
* All valid audiences.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
public const VALID_AUDIENCES = [
|
||||
self::AUDIENCE_ALL,
|
||||
self::AUDIENCE_CHILD,
|
||||
];
|
||||
|
||||
/**
|
||||
* Build an intake question value object.
|
||||
*
|
||||
* `$isRequired` and `$isRequiredChild` are deliberately separate: a studio may
|
||||
* want an answer from every student it enrols without demanding the same of an
|
||||
* adult signing themselves up. Read them through {@see isRequiredForSelf()} and
|
||||
* {@see isRequiredForChild()} rather than directly, so the audience is applied
|
||||
* with them.
|
||||
*
|
||||
* Both `$audience` and `$isRequiredChild` are meaningless for offering scope,
|
||||
* where a booking asks its questions once about the student being booked and
|
||||
* there is no separate account-holder form to differ from.
|
||||
*
|
||||
* @param int|null $offeringId The owning offering, or null for account-scoped questions.
|
||||
* @param list<string>|null $options Choices for a `select` field.
|
||||
*/
|
||||
@@ -58,9 +88,36 @@ class Question {
|
||||
public readonly int $sortOrder = 0,
|
||||
public readonly bool $isActive = true,
|
||||
public readonly string $scope = self::SCOPE_OFFERING,
|
||||
public readonly string $audience = self::AUDIENCE_ALL,
|
||||
public readonly bool $isRequiredChild = false,
|
||||
public readonly ?int $id = null,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Whether the account holder is asked this question in their own right — true
|
||||
* for everything except a child-audience question.
|
||||
*/
|
||||
public function askedOfSelf(): bool {
|
||||
return self::AUDIENCE_CHILD !== $this->audience;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the account holder must answer before the form will submit. A
|
||||
* child-audience question never reaches them, so it can never block them.
|
||||
*/
|
||||
public function isRequiredForSelf(): bool {
|
||||
return $this->isRequired && $this->askedOfSelf();
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether each student being registered must answer before the form will
|
||||
* submit. Every question is asked in the student blocks whatever its audience,
|
||||
* so this stands on its own.
|
||||
*/
|
||||
public function isRequiredForChild(): bool {
|
||||
return $this->isRequiredChild;
|
||||
}
|
||||
|
||||
public static function fromRow( \stdClass $row ): self {
|
||||
$options = null;
|
||||
if ( null !== $row->options && '' !== $row->options ) {
|
||||
@@ -70,6 +127,13 @@ class Question {
|
||||
: null;
|
||||
}
|
||||
|
||||
// `audience` and `is_required_child` arrived after the table did, so a row
|
||||
// read on a site whose dbDelta has not run yet simply lacks them: the
|
||||
// pre-existing behaviour (asked of everyone, required of nobody in
|
||||
// particular) is the right reading of a question authored before the
|
||||
// distinction existed.
|
||||
$audience = Val::string( $row->audience ?? '' );
|
||||
|
||||
return new self(
|
||||
offeringId: Val::intOrNull( $row->offering_id ),
|
||||
label: Val::string( $row->label ),
|
||||
@@ -79,6 +143,8 @@ class Question {
|
||||
sortOrder: Val::int( $row->sort_order ),
|
||||
isActive: Val::bool( $row->is_active ),
|
||||
scope: Val::string( $row->scope ),
|
||||
audience: in_array( $audience, self::VALID_AUDIENCES, true ) ? $audience : self::AUDIENCE_ALL,
|
||||
isRequiredChild: Val::bool( $row->is_required_child ?? false ),
|
||||
id: Val::int( $row->id ),
|
||||
);
|
||||
}
|
||||
@@ -96,7 +162,9 @@ class Question {
|
||||
'label' => $this->label,
|
||||
'field_type' => $this->fieldType,
|
||||
'options' => $this->options,
|
||||
'audience' => $this->audience,
|
||||
'is_required' => $this->isRequired,
|
||||
'is_required_child' => $this->isRequiredChild,
|
||||
'sort_order' => $this->sortOrder,
|
||||
'is_active' => $this->isActive,
|
||||
];
|
||||
|
||||
@@ -89,15 +89,25 @@ class QuestionController {
|
||||
return;
|
||||
}
|
||||
|
||||
// Audience and the students' own required-ness are asked for on the
|
||||
// account-scope form only; an offering's questions are answered once about
|
||||
// the student being booked, so there is no second audience to differ from.
|
||||
// An offering question therefore mirrors its single "required" into both
|
||||
// columns rather than storing a distinction it does not have.
|
||||
$accountScope = null === $offering;
|
||||
$audience = sanitize_key( Val::string( wp_unslash( $_POST['audience'] ?? '' ) ) );
|
||||
|
||||
$this->questions->insert(
|
||||
new Question(
|
||||
offeringId: null === $offering ? null : (int) $offering->id,
|
||||
offeringId: $accountScope ? null : (int) $offering->id,
|
||||
label: $label,
|
||||
fieldType: $fieldType,
|
||||
options: $this->parseOptions( sanitize_textarea_field( Val::string( wp_unslash( $_POST['options'] ?? '' ) ) ) ),
|
||||
isRequired: isset( $_POST['is_required'] ),
|
||||
sortOrder: absint( Val::int( $_POST['sort_order'] ?? 0 ) ),
|
||||
scope: null === $offering ? Question::SCOPE_ACCOUNT : Question::SCOPE_OFFERING,
|
||||
scope: $accountScope ? Question::SCOPE_ACCOUNT : Question::SCOPE_OFFERING,
|
||||
audience: $accountScope && in_array( $audience, Question::VALID_AUDIENCES, true ) ? $audience : Question::AUDIENCE_ALL,
|
||||
isRequiredChild: $accountScope ? isset( $_POST['is_required_child'] ) : isset( $_POST['is_required'] ),
|
||||
)
|
||||
);
|
||||
// phpcs:enable WordPress.Security.NonceVerification.Missing
|
||||
|
||||
@@ -88,14 +88,21 @@ class QuestionEndpoint {
|
||||
return $this->invalid( __( 'Invalid field type.', 'unsupervised-schedular' ) );
|
||||
}
|
||||
|
||||
$isRequired = (bool) $request->get_param( 'is_required' );
|
||||
|
||||
$question = new Question(
|
||||
offeringId: $offeringId,
|
||||
label: $label,
|
||||
fieldType: $fieldType,
|
||||
options: $this->sanitizeOptions( $request->get_param( 'options' ) ),
|
||||
isRequired: (bool) $request->get_param( 'is_required' ),
|
||||
isRequired: $isRequired,
|
||||
sortOrder: Val::int( $request->get_param( 'sort_order' ) ),
|
||||
isActive: null === $request->get_param( 'is_active' ) ? true : (bool) $request->get_param( 'is_active' ),
|
||||
// An offering asks its questions once, about the student being booked,
|
||||
// so there is no second audience to differ from: the single "required"
|
||||
// stands for both, the same way the upgrade backfill left every
|
||||
// question authored before the two could differ.
|
||||
isRequiredChild: $isRequired,
|
||||
);
|
||||
|
||||
$id = $this->questions->insert( $question );
|
||||
@@ -129,15 +136,22 @@ class QuestionEndpoint {
|
||||
return $this->invalid( $this->tooLongMessage( __( 'question', 'unsupervised-schedular' ), Question::MAX_LABEL_LENGTH ) );
|
||||
}
|
||||
|
||||
// Only offering-scope questions reach here — an account-scope one has no
|
||||
// offering to own it and is turned away as not found above — so the same
|
||||
// single "required" applies to everyone asked. See create().
|
||||
$isRequired = $request->has_param( 'is_required' ) ? (bool) $request->get_param( 'is_required' ) : $existing->isRequired;
|
||||
|
||||
$question = new Question(
|
||||
offeringId: $existing->offeringId,
|
||||
label: $label,
|
||||
fieldType: $fieldType,
|
||||
options: $request->has_param( 'options' ) ? $this->sanitizeOptions( $request->get_param( 'options' ) ) : $existing->options,
|
||||
isRequired: $request->has_param( 'is_required' ) ? (bool) $request->get_param( 'is_required' ) : $existing->isRequired,
|
||||
isRequired: $isRequired,
|
||||
sortOrder: $request->has_param( 'sort_order' ) ? Val::int( $request->get_param( 'sort_order' ) ) : $existing->sortOrder,
|
||||
isActive: $request->has_param( 'is_active' ) ? (bool) $request->get_param( 'is_active' ) : $existing->isActive,
|
||||
scope: $existing->scope,
|
||||
audience: $existing->audience,
|
||||
isRequiredChild: $isRequired,
|
||||
id: $id,
|
||||
);
|
||||
|
||||
|
||||
@@ -21,12 +21,18 @@ class QuestionField {
|
||||
* the HTML attribute, for a block the browser must not block submission on
|
||||
* because it may not apply at all — the child blocks, which only count when
|
||||
* the parent/guardian box is ticked. The server validates those either way.
|
||||
*
|
||||
* `$isRequired` overrides which of the question's two required flags applies
|
||||
* here — a question can be optional for the account holder and required for
|
||||
* each student they register, and only the caller knows which block this is.
|
||||
* Null falls back to the question's own {@see Question::$isRequired}.
|
||||
*/
|
||||
public static function render( Question $question, string $name, string $id, bool $enforceRequired = true ): string {
|
||||
$required = $question->isRequired && $enforceRequired ? ' required' : '';
|
||||
public static function render( Question $question, string $name, string $id, bool $enforceRequired = true, ?bool $isRequired = null ): string {
|
||||
$mustAnswer = $isRequired ?? $question->isRequired;
|
||||
$required = $mustAnswer && $enforceRequired ? ' required' : '';
|
||||
|
||||
$label = '<label for="' . esc_attr( $id ) . '">' . esc_html( $question->label )
|
||||
. ( $question->isRequired ? ' <span class="us-required" aria-hidden="true">*</span>' : '' )
|
||||
. ( $mustAnswer ? ' <span class="us-required" aria-hidden="true">*</span>' : '' )
|
||||
. '</label>';
|
||||
|
||||
return '<p>' . $label . self::input( $question, $name, $id, $required ) . '</p>';
|
||||
|
||||
@@ -15,7 +15,7 @@ class QuestionRepository {
|
||||
$this->db->insert(
|
||||
$this->table,
|
||||
$this->columns( $question ) + [ 'created_at' => current_time( 'mysql' ) ],
|
||||
[ '%d', '%s', '%s', '%s', '%s', '%d', '%d', '%d', '%s' ]
|
||||
[ '%d', '%s', '%s', '%s', '%s', '%s', '%d', '%d', '%d', '%d', '%s' ]
|
||||
);
|
||||
|
||||
return $this->db->insert_id;
|
||||
@@ -26,7 +26,7 @@ class QuestionRepository {
|
||||
$this->table,
|
||||
$this->columns( $question ),
|
||||
[ 'id' => $id ],
|
||||
[ '%d', '%s', '%s', '%s', '%s', '%d', '%d', '%d' ],
|
||||
[ '%d', '%s', '%s', '%s', '%s', '%s', '%d', '%d', '%d', '%d' ],
|
||||
[ '%d' ]
|
||||
);
|
||||
}
|
||||
@@ -43,7 +43,9 @@ class QuestionRepository {
|
||||
'label' => $question->label,
|
||||
'field_type' => $question->fieldType,
|
||||
'options' => null === $question->options ? null : (string) wp_json_encode( $question->options ),
|
||||
'audience' => $question->audience,
|
||||
'is_required' => $question->isRequired ? 1 : 0,
|
||||
'is_required_child' => $question->isRequiredChild ? 1 : 0,
|
||||
'sort_order' => $question->sortOrder,
|
||||
'is_active' => $question->isActive ? 1 : 0,
|
||||
];
|
||||
@@ -128,4 +130,30 @@ class QuestionRepository {
|
||||
|
||||
return null !== $sql && false !== $this->db->query( $sql );
|
||||
}
|
||||
|
||||
/**
|
||||
* Give every question authored before students had a required-ness of their
|
||||
* own the one it used to have.
|
||||
*
|
||||
* `is_required_child` arrives with `DEFAULT 0`, so without this a question the
|
||||
* studio had marked required would quietly stop being required of the students
|
||||
* a guardian registers — the case it most likely existed for. Copying
|
||||
* `is_required` across preserves exactly the old behaviour: required of
|
||||
* everyone, or of nobody.
|
||||
*
|
||||
* Run once, guarded by an option in {@see \Unsupervised\Schedular\Plugin::boot()},
|
||||
* so a question deliberately made optional for students afterwards is not
|
||||
* quietly made required again.
|
||||
*
|
||||
* @return bool True when the statement ran, false if it could not be prepared
|
||||
* or the query failed.
|
||||
*/
|
||||
public function backfillChildRequired(): bool {
|
||||
$sql = $this->db->prepare(
|
||||
'UPDATE %i SET is_required_child = 1 WHERE is_required = 1',
|
||||
$this->table
|
||||
);
|
||||
|
||||
return null !== $sql && false !== $this->db->query( $sql );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,7 +85,9 @@ class Schema {
|
||||
label VARCHAR(255) NOT NULL,
|
||||
field_type VARCHAR(20) NOT NULL DEFAULT 'text',
|
||||
options TEXT,
|
||||
audience VARCHAR(20) NOT NULL DEFAULT 'all',
|
||||
is_required TINYINT(1) NOT NULL DEFAULT 0,
|
||||
is_required_child TINYINT(1) NOT NULL DEFAULT 0,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_at DATETIME NOT NULL,
|
||||
|
||||
@@ -41,7 +41,7 @@ if (! defined('ABSPATH')) {
|
||||
<?php else : ?>
|
||||
<?php if ($accountScope) : ?>
|
||||
<h2><?php esc_html_e('Account signup questions', 'unsupervised-schedular'); ?></h2>
|
||||
<p><?php esc_html_e('Every new student answers these required-if-marked questions when they register — the account holder on the signup form itself, and once per student they are registering on behalf of.', 'unsupervised-schedular'); ?></p>
|
||||
<p><?php esc_html_e('Every new student answers these questions when they register — the account holder on the signup form itself, and once per student they are registering on behalf of. Each question says who it is asked of, and can be required of the account holder, of the students, or of both.', 'unsupervised-schedular'); ?></p>
|
||||
<?php else : ?>
|
||||
<h2><?php echo esc_html(sprintf(/* translators: %s: offering title */ __('Questions for "%s"', 'unsupervised-schedular'), $selectedOffering->title)); ?></h2>
|
||||
<?php endif; ?>
|
||||
@@ -76,10 +76,31 @@ if (! defined('ABSPATH')) {
|
||||
<th><label for="sort_order"><?php esc_html_e('Sort order', 'unsupervised-schedular'); ?></label></th>
|
||||
<td><input type="number" name="sort_order" id="sort_order" min="0" step="1" value="0"></td>
|
||||
</tr>
|
||||
<?php if ($accountScope) : ?>
|
||||
<tr>
|
||||
<th><label for="audience"><?php esc_html_e('Asked of', 'unsupervised-schedular'); ?></label></th>
|
||||
<td>
|
||||
<select name="audience" id="audience">
|
||||
<option value="<?php echo esc_attr(Question::AUDIENCE_ALL); ?>"><?php esc_html_e('Everyone registering', 'unsupervised-schedular'); ?></option>
|
||||
<option value="<?php echo esc_attr(Question::AUDIENCE_CHILD); ?>"><?php esc_html_e('Students only — not the account holder', 'unsupervised-schedular'); ?></option>
|
||||
</select>
|
||||
<p class="description"><?php esc_html_e('"Students only" leaves the question off the account holder\'s own section, for anything that only makes sense about a student someone is registering on behalf of.', 'unsupervised-schedular'); ?></p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th><?php esc_html_e('Required', 'unsupervised-schedular'); ?></th>
|
||||
<td>
|
||||
<label><input type="checkbox" name="is_required" value="1"> <?php esc_html_e('The account holder must answer for themselves', 'unsupervised-schedular'); ?></label><br>
|
||||
<label><input type="checkbox" name="is_required_child" value="1"> <?php esc_html_e('Each student they register must answer', 'unsupervised-schedular'); ?></label>
|
||||
<p class="description"><?php esc_html_e('Tick either, both, or neither — a question can be optional for an adult signing themselves up and still required for every student they enrol.', 'unsupervised-schedular'); ?></p>
|
||||
</td>
|
||||
</tr>
|
||||
<?php else : ?>
|
||||
<tr>
|
||||
<th><?php esc_html_e('Required', 'unsupervised-schedular'); ?></th>
|
||||
<td><label><input type="checkbox" name="is_required" value="1"> <?php esc_html_e('Registrant must answer', 'unsupervised-schedular'); ?></label></td>
|
||||
</tr>
|
||||
<?php endif; ?>
|
||||
</table>
|
||||
<?php submit_button(esc_html__('Add Question', 'unsupervised-schedular')); ?>
|
||||
</form>
|
||||
@@ -94,7 +115,12 @@ if (! defined('ABSPATH')) {
|
||||
<th><?php esc_html_e('Order', 'unsupervised-schedular'); ?></th>
|
||||
<th><?php esc_html_e('Question', 'unsupervised-schedular'); ?></th>
|
||||
<th><?php esc_html_e('Type', 'unsupervised-schedular'); ?></th>
|
||||
<?php if ($accountScope) : ?>
|
||||
<th><?php esc_html_e('Asked of', 'unsupervised-schedular'); ?></th>
|
||||
<th><?php esc_html_e('Required of', 'unsupervised-schedular'); ?></th>
|
||||
<?php else : ?>
|
||||
<th><?php esc_html_e('Required', 'unsupervised-schedular'); ?></th>
|
||||
<?php endif; ?>
|
||||
<th><?php esc_html_e('Actions', 'unsupervised-schedular'); ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -104,7 +130,32 @@ if (! defined('ABSPATH')) {
|
||||
<td><?php echo esc_html((string) $question->sortOrder); ?></td>
|
||||
<td><?php echo esc_html($question->label); ?></td>
|
||||
<td><?php echo esc_html($question->fieldType); ?></td>
|
||||
<?php if ($accountScope) : ?>
|
||||
<td>
|
||||
<?php
|
||||
echo $question->askedOfSelf()
|
||||
? esc_html__('Everyone', 'unsupervised-schedular')
|
||||
: esc_html__('Students only', 'unsupervised-schedular');
|
||||
?>
|
||||
</td>
|
||||
<td>
|
||||
<?php
|
||||
// Named rather than two ticks, so "required of the
|
||||
// students but not of you" reads as the deliberate
|
||||
// setting it is rather than as a half-filled row.
|
||||
$requiredOf = [];
|
||||
if ($question->isRequiredForSelf()) {
|
||||
$requiredOf[] = __('account holder', 'unsupervised-schedular');
|
||||
}
|
||||
if ($question->isRequiredForChild()) {
|
||||
$requiredOf[] = __('students', 'unsupervised-schedular');
|
||||
}
|
||||
echo esc_html([] === $requiredOf ? __('—', 'unsupervised-schedular') : implode(', ', $requiredOf));
|
||||
?>
|
||||
</td>
|
||||
<?php else : ?>
|
||||
<td><?php echo $question->isRequired ? esc_html__('Yes', 'unsupervised-schedular') : esc_html__('No', 'unsupervised-schedular'); ?></td>
|
||||
<?php endif; ?>
|
||||
<td>
|
||||
<form method="post" style="display:inline;">
|
||||
<?php wp_nonce_field('usc_question_action'); ?>
|
||||
|
||||
@@ -8,6 +8,7 @@ if (! defined('ABSPATH')) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @var array{name: string, email: string, birth_year: string, is_student: bool} $self The account holder's own details.
|
||||
* @var list<array{id: int, name: string, birth_year: string, relationship: string}> $children
|
||||
* @var list<\Unsupervised\Schedular\Registration\Question> $questions Account-scope questions, asked once per child.
|
||||
* @var string $error Validation error from the last submission, if any.
|
||||
@@ -26,6 +27,52 @@ if (! defined('ABSPATH')) {
|
||||
<p class="us-error" role="alert"><?php echo esc_html($error); ?></p>
|
||||
<?php endif; ?>
|
||||
|
||||
<form method="post" action="" class="us-family-self">
|
||||
<?php wp_nonce_field('us_family'); ?>
|
||||
<input type="hidden" name="us_family_action" value="self">
|
||||
|
||||
<h4><?php esc_html_e('Your details', 'unsupervised-schedular'); ?></h4>
|
||||
|
||||
<?php if ($self['email'] !== '') : ?>
|
||||
<?php /* Shown, not editable: the address is the account's login, and changing it is a studio-side job. */ ?>
|
||||
<p class="us-family-self-email">
|
||||
<?php esc_html_e('Email', 'unsupervised-schedular'); ?>
|
||||
<span><?php echo esc_html($self['email']); ?></span>
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
|
||||
<p>
|
||||
<label for="us-own-name"><?php esc_html_e('Your name', 'unsupervised-schedular'); ?> <span class="us-required" aria-hidden="true">*</span></label>
|
||||
<input type="text" name="own_name" id="us-own-name" autocomplete="name" required value="<?php echo esc_attr($self['name']); ?>">
|
||||
</p>
|
||||
<p>
|
||||
<label>
|
||||
<input type="checkbox" name="is_student" value="1"<?php checked($self['is_student']); ?>>
|
||||
<?php esc_html_e('I take lessons myself', 'unsupervised-schedular'); ?>
|
||||
</label>
|
||||
<span class="us-field-hint"><?php esc_html_e('Leave this unticked if you only book for the students below — you will not be offered as a student yourself.', 'unsupervised-schedular'); ?></span>
|
||||
</p>
|
||||
<?php
|
||||
/*
|
||||
* Asked of a student only, so `required` is deliberately absent: the box
|
||||
* above is what decides, and the browser cannot be told to enforce a
|
||||
* field conditionally without JavaScript this page does not load.
|
||||
* `handleSelf()` enforces it on the server either way.
|
||||
*/
|
||||
?>
|
||||
<p>
|
||||
<label for="us-own-birth-year"><?php esc_html_e('Your birth year', 'unsupervised-schedular'); ?></label>
|
||||
<input type="number" name="own_birth_year" id="us-own-birth-year" value="<?php echo esc_attr($self['birth_year']); ?>" min="1900" max="<?php echo esc_attr(current_time('Y')); ?>" step="1" inputmode="numeric" autocomplete="bday-year" placeholder="<?php esc_attr_e('YYYY', 'unsupervised-schedular'); ?>" aria-describedby="us-own-birth-year-hint">
|
||||
<span class="us-field-hint" id="us-own-birth-year-hint"><?php esc_html_e('Needed only if you take lessons yourself.', 'unsupervised-schedular'); ?></span>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<button type="submit"><?php esc_html_e('Save my details', 'unsupervised-schedular'); ?></button>
|
||||
</p>
|
||||
</form>
|
||||
|
||||
<h4><?php esc_html_e('Your students', 'unsupervised-schedular'); ?></h4>
|
||||
|
||||
<?php if (empty($children)) : ?>
|
||||
<p><?php esc_html_e('You have not added any students yet. Add one below to start booking lessons for them.', 'unsupervised-schedular'); ?></p>
|
||||
<?php else : ?>
|
||||
@@ -94,7 +141,12 @@ if (! defined('ABSPATH')) {
|
||||
<?php foreach ($questions as $question) : ?>
|
||||
<?php
|
||||
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- QuestionField::render() escapes every interpolated value.
|
||||
echo QuestionField::render($question, 'us_answers[' . (int) $question->id . ']', 'us-family-q-' . (int) $question->id);
|
||||
echo QuestionField::render(
|
||||
$question,
|
||||
'us_answers[' . (int) $question->id . ']',
|
||||
'us-family-q-' . (int) $question->id,
|
||||
isRequired: $question->isRequiredForChild()
|
||||
);
|
||||
?>
|
||||
<?php endforeach; ?>
|
||||
</fieldset>
|
||||
|
||||
@@ -22,7 +22,7 @@ if (! defined('ABSPATH')) {
|
||||
* @var string $loginUrl Where the post-confirmation sign-in link points.
|
||||
* @var string $error
|
||||
* @var list<array{policy: \Unsupervised\Schedular\Policy\Policy, version: \Unsupervised\Schedular\Policy\PolicyVersion}> $policyForms
|
||||
* @var list<Question> $accountQuestions Studio-wide questions, asked of every student — the account holder included when they are one.
|
||||
* @var list<Question> $accountQuestions Studio-wide questions, asked of every student being registered — the account holder included when they are one, unless the question is for students only.
|
||||
*/
|
||||
|
||||
?>
|
||||
@@ -121,9 +121,21 @@ if (! defined('ABSPATH')) {
|
||||
<input type="number" name="birth_year" id="us-reg-birth-year" aria-required="true" required min="1900" max="<?php echo esc_attr(current_time('Y')); ?>" step="1" inputmode="numeric" autocomplete="bday-year" placeholder="<?php esc_attr_e('YYYY', 'unsupervised-schedular'); ?>">
|
||||
</p>
|
||||
<?php foreach ($accountQuestions as $question) : ?>
|
||||
<?php
|
||||
// A "students only" question describes a child being registered,
|
||||
// so it is never put to the account holder about themselves.
|
||||
if (! $question->askedOfSelf()) {
|
||||
continue;
|
||||
}
|
||||
?>
|
||||
<?php
|
||||
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- QuestionField::render() escapes every interpolated value.
|
||||
echo QuestionField::render($question, 'us_answers[' . (int) $question->id . ']', 'us-reg-q-' . (int) $question->id);
|
||||
echo QuestionField::render(
|
||||
$question,
|
||||
'us_answers[' . (int) $question->id . ']',
|
||||
'us-reg-q-' . (int) $question->id,
|
||||
isRequired: $question->isRequiredForSelf()
|
||||
);
|
||||
?>
|
||||
<?php endforeach; ?>
|
||||
</fieldset>
|
||||
@@ -150,7 +162,8 @@ if (! defined('ABSPATH')) {
|
||||
$question,
|
||||
'children[0][answers][' . (int) $question->id . ']',
|
||||
'us-child-0-q-' . (int) $question->id,
|
||||
enforceRequired: false
|
||||
enforceRequired: false,
|
||||
isRequired: $question->isRequiredForChild()
|
||||
);
|
||||
?>
|
||||
<?php endforeach; ?>
|
||||
|
||||
@@ -1017,7 +1017,7 @@ class RegistrationPageTest extends TestCase
|
||||
];
|
||||
|
||||
$this->ctx['questions']->shouldReceive('findByScope')->andReturn([
|
||||
new Question(offeringId: null, label: 'Instrument', isRequired: true, scope: Question::SCOPE_ACCOUNT, id: 7),
|
||||
new Question(offeringId: null, label: 'Instrument', isRequired: true, scope: Question::SCOPE_ACCOUNT, isRequiredChild: true, id: 7),
|
||||
]);
|
||||
|
||||
Functions\when('email_exists')->justReturn(false);
|
||||
@@ -1247,4 +1247,157 @@ class RegistrationPageTest extends TestCase
|
||||
'The account holder answers the questions above the students they are adding.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A "students only" question describes a child being registered, so it is put
|
||||
* to each student and never to the account holder about themselves.
|
||||
*/
|
||||
public function testAStudentsOnlyQuestionIsAskedOfTheStudentsAndNotOfTheAccountHolder(): void
|
||||
{
|
||||
$this->stubRenderContext();
|
||||
|
||||
$question = new Question(
|
||||
null,
|
||||
'School and grade',
|
||||
scope: Question::SCOPE_ACCOUNT,
|
||||
audience: Question::AUDIENCE_CHILD,
|
||||
isRequiredChild: true,
|
||||
id: 7
|
||||
);
|
||||
$this->ctx['questions']->shouldReceive('findByScope')->with(Question::SCOPE_ACCOUNT, Mockery::any())->andReturn([$question]);
|
||||
|
||||
$html = $this->ctx['page']->render([]);
|
||||
|
||||
self::assertStringNotContainsString('name="us_answers[7]"', $html);
|
||||
self::assertStringContainsString('name="children[0][answers][7]"', $html);
|
||||
}
|
||||
|
||||
/**
|
||||
* The two required flags are read where each applies: the browser is asked to
|
||||
* enforce the account holder's, and the students' block carries the marker
|
||||
* without the attribute (it may not be in play at all).
|
||||
*/
|
||||
public function testTheFormMarksAQuestionRequiredWhereItActuallyIs(): void
|
||||
{
|
||||
$this->stubRenderContext();
|
||||
|
||||
$question = new Question(
|
||||
null,
|
||||
'Previous experience',
|
||||
scope: Question::SCOPE_ACCOUNT,
|
||||
isRequired: false,
|
||||
isRequiredChild: true,
|
||||
id: 7
|
||||
);
|
||||
$this->ctx['questions']->shouldReceive('findByScope')->with(Question::SCOPE_ACCOUNT, Mockery::any())->andReturn([$question]);
|
||||
|
||||
$html = $this->ctx['page']->render([]);
|
||||
|
||||
// No `required` attribute on the account holder's copy, and no marker on
|
||||
// its label — they may leave it blank.
|
||||
self::assertStringContainsString('<input type="text" name="us_answers[7]" id="us-reg-q-7">', $html);
|
||||
self::assertStringContainsString('<label for="us-reg-q-7">Previous experience</label>', $html);
|
||||
|
||||
// The student's copy is marked required, without the attribute: the block
|
||||
// may not be in play at all, so the server is what enforces it.
|
||||
self::assertStringContainsString('<label for="us-child-0-q-7">Previous experience <span class="us-required" aria-hidden="true">*</span></label>', $html);
|
||||
self::assertStringContainsString('<input type="text" name="children[0][answers][7]" id="us-child-0-q-7">', $html);
|
||||
}
|
||||
|
||||
/**
|
||||
* The point of the two flags: an adult signing themselves up can leave the
|
||||
* question blank, while every student they enrol must answer it.
|
||||
*/
|
||||
public function testAQuestionOptionalForYouIsStillRequiredOfEachStudent(): void
|
||||
{
|
||||
$_POST = [
|
||||
'password' => 'thistle-marrow-42',
|
||||
'display_name' => 'Grace',
|
||||
'birth_year' => '1990',
|
||||
'us_registering_for' => RegistrationPage::FOR_BOTH,
|
||||
'us_answers' => ['7' => ' '],
|
||||
'children' => [['name' => 'Ada', 'birth_year' => '2015', 'answers' => [7 => ' ']]],
|
||||
];
|
||||
|
||||
$question = new Question(null, 'Instrument', isRequired: false, scope: Question::SCOPE_ACCOUNT, isRequiredChild: true, id: 7);
|
||||
$this->ctx['questions']->shouldReceive('findByScope')->andReturn([$question]);
|
||||
Functions\when('email_exists')->justReturn(false);
|
||||
Functions\expect('wp_insert_user')->never();
|
||||
|
||||
$result = $this->submit(new Invite(email: '[email protected]', token: 'hash'), false);
|
||||
|
||||
// The student's blank is what stopped it — the account holder's was fine.
|
||||
self::assertStringContainsString('for each student', $result);
|
||||
}
|
||||
|
||||
public function testTheAccountHolderMayLeaveBlankWhatTheirStudentsMustAnswer(): void
|
||||
{
|
||||
$_POST = [
|
||||
'password' => 'thistle-marrow-42',
|
||||
'display_name' => 'Grace',
|
||||
'birth_year' => '1990',
|
||||
'us_registering_for' => RegistrationPage::FOR_BOTH,
|
||||
'us_answers' => ['7' => ' '],
|
||||
'children' => [['name' => 'Ada', 'birth_year' => '2015', 'answers' => [7 => 'Piano']]],
|
||||
];
|
||||
|
||||
$question = new Question(null, 'Instrument', isRequired: false, scope: Question::SCOPE_ACCOUNT, isRequiredChild: true, id: 7);
|
||||
$this->ctx['questions']->shouldReceive('findByScope')->andReturn([$question]);
|
||||
$this->ctx['guardians']->shouldReceive('createChild')->once()->andReturn(101);
|
||||
$this->stubInviteSuccess();
|
||||
|
||||
$students = [];
|
||||
$this->ctx['answers']->shouldReceive('insert')->andReturnUsing(
|
||||
static function (Answer $answer) use (&$students): int {
|
||||
$students[] = $answer->studentId;
|
||||
return 1;
|
||||
}
|
||||
);
|
||||
|
||||
self::assertSame('invite', $this->submit(new Invite(email: '[email protected]', token: 'hash'), false));
|
||||
|
||||
// Only the student answered, so only the student has an answer stored.
|
||||
self::assertSame([101], $students);
|
||||
}
|
||||
|
||||
/**
|
||||
* A question the account holder is never shown cannot be one they are held
|
||||
* to, nor one an answer can be filed against them for — a crafted post that
|
||||
* supplies both is ignored on both counts.
|
||||
*/
|
||||
public function testAStudentsOnlyQuestionNeitherBlocksNorStoresAgainstTheAccountHolder(): void
|
||||
{
|
||||
$_POST = [
|
||||
'password' => 'thistle-marrow-42',
|
||||
'display_name' => 'Grace',
|
||||
'birth_year' => '1990',
|
||||
'us_registering_for' => RegistrationPage::FOR_BOTH,
|
||||
'us_answers' => ['7' => 'Crafted by hand'],
|
||||
'children' => [['name' => 'Ada', 'birth_year' => '2015', 'answers' => [7 => 'Grade 4']]],
|
||||
];
|
||||
|
||||
$question = new Question(
|
||||
null,
|
||||
'School and grade',
|
||||
isRequired: true,
|
||||
scope: Question::SCOPE_ACCOUNT,
|
||||
audience: Question::AUDIENCE_CHILD,
|
||||
isRequiredChild: true,
|
||||
id: 7
|
||||
);
|
||||
$this->ctx['questions']->shouldReceive('findByScope')->andReturn([$question]);
|
||||
$this->ctx['guardians']->shouldReceive('createChild')->once()->andReturn(101);
|
||||
$this->stubInviteSuccess();
|
||||
|
||||
$recorded = [];
|
||||
$this->ctx['answers']->shouldReceive('insert')->andReturnUsing(
|
||||
static function (Answer $answer) use (&$recorded): int {
|
||||
$recorded[] = [$answer->studentId, $answer->answerValue];
|
||||
return 1;
|
||||
}
|
||||
);
|
||||
|
||||
self::assertSame('invite', $this->submit(new Invite(email: '[email protected]', token: 'hash'), false));
|
||||
self::assertSame([[101, 'Grade 4']], $recorded);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,6 +74,23 @@ class BlockPreviewTest extends TestCase
|
||||
self::assertStringContainsString('us-editor-note', $html);
|
||||
}
|
||||
|
||||
/**
|
||||
* The preview is what someone placing the block styles against, so it has to
|
||||
* show both halves of the page — your own details as well as your students'.
|
||||
*/
|
||||
public function testFamilyPreviewShowsTheAccountHoldersDetailsAndTheirStudents(): void
|
||||
{
|
||||
$html = BlockPreview::family();
|
||||
|
||||
self::assertStringContainsString('class="us-family-self"', $html);
|
||||
self::assertStringContainsString('id="us-own-name"', $html);
|
||||
self::assertStringContainsString('id="us-own-birth-year"', $html);
|
||||
self::assertStringContainsString('I take lessons myself', $html);
|
||||
self::assertStringContainsString('class="us-family-list"', $html);
|
||||
self::assertStringContainsString('class="us-family-add"', $html);
|
||||
self::assertStringContainsString('us-editor-note', $html);
|
||||
}
|
||||
|
||||
public function testRegistrationPreviewShowsADisabledSampleForm(): void
|
||||
{
|
||||
$html = BlockPreview::registration();
|
||||
|
||||
@@ -76,9 +76,36 @@ class FamilyPageTest extends TestCase
|
||||
return $page;
|
||||
}
|
||||
|
||||
/**
|
||||
* The account holder's own details, which every render reads.
|
||||
*
|
||||
* @param array{name?: string, email?: string, birth_year?: string, is_student?: bool} $overrides
|
||||
*/
|
||||
private function expectAccountHolder(array $overrides = []): void
|
||||
{
|
||||
$this->guardians->shouldReceive('accountHolder')->with(5)->andReturn($overrides + [
|
||||
'name' => 'Grace',
|
||||
'email' => '[email protected]',
|
||||
'birth_year' => '1984',
|
||||
'is_student' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* A question required of everyone, or of nobody — the shape every question
|
||||
* had before the account holder and the students could differ, and the shape
|
||||
* the upgrade backfill leaves them in.
|
||||
*/
|
||||
private function question(int $id, bool $required): Question
|
||||
{
|
||||
return new Question(offeringId: null, label: 'Instrument', isRequired: $required, scope: Question::SCOPE_ACCOUNT, id: $id);
|
||||
return new Question(
|
||||
offeringId: null,
|
||||
label: 'Instrument',
|
||||
isRequired: $required,
|
||||
scope: Question::SCOPE_ACCOUNT,
|
||||
isRequiredChild: $required,
|
||||
id: $id
|
||||
);
|
||||
}
|
||||
|
||||
public function testLoggedOutVisitorIsOfferedALoginLink(): void
|
||||
@@ -93,6 +120,7 @@ class FamilyPageTest extends TestCase
|
||||
|
||||
public function testRenderListsTheGuardiansChildren(): void
|
||||
{
|
||||
$this->expectAccountHolder();
|
||||
$this->guardians->shouldReceive('children')->once()->with(5)->andReturn([
|
||||
['id' => 42, 'name' => 'Ada', 'birth_year' => '2015', 'relationship' => 'Parent'],
|
||||
]);
|
||||
@@ -105,6 +133,101 @@ class FamilyPageTest extends TestCase
|
||||
self::assertStringContainsString('Add a student', $html);
|
||||
}
|
||||
|
||||
public function testRenderShowsTheAccountHoldersOwnDetails(): void
|
||||
{
|
||||
$this->expectAccountHolder();
|
||||
$this->guardians->shouldReceive('children')->andReturn([]);
|
||||
$this->questions->shouldReceive('findByScope')->andReturn([]);
|
||||
|
||||
$html = $this->page->render([]);
|
||||
|
||||
self::assertStringContainsString('Your details', $html);
|
||||
self::assertStringContainsString('value="Grace"', $html);
|
||||
self::assertStringContainsString('[email protected]', $html);
|
||||
self::assertStringContainsString('value="1984"', $html);
|
||||
// A student in their own right has the box ticked.
|
||||
self::assertStringContainsString("checked='checked'", $html);
|
||||
}
|
||||
|
||||
public function testAGuardianOnlyAccountRendersTheStudentBoxUnticked(): void
|
||||
{
|
||||
$this->expectAccountHolder(['is_student' => false]);
|
||||
$this->guardians->shouldReceive('children')->andReturn([]);
|
||||
$this->questions->shouldReceive('findByScope')->andReturn([]);
|
||||
|
||||
$html = $this->page->render([]);
|
||||
|
||||
self::assertStringContainsString('name="is_student"', $html);
|
||||
self::assertStringNotContainsString("checked='checked'", $html);
|
||||
}
|
||||
|
||||
/**
|
||||
* The birth-year field must not carry `required`: it is asked of a student
|
||||
* only, and the browser would otherwise block a guardian who books solely
|
||||
* for other people from ever saving the form.
|
||||
*/
|
||||
public function testTheOwnBirthYearFieldIsNotBrowserRequired(): void
|
||||
{
|
||||
$this->expectAccountHolder(['is_student' => false, 'birth_year' => '']);
|
||||
$this->guardians->shouldReceive('children')->andReturn([]);
|
||||
$this->questions->shouldReceive('findByScope')->andReturn([]);
|
||||
|
||||
$html = $this->page->render([]);
|
||||
|
||||
self::assertMatchesRegularExpression('/<input[^>]*name="own_birth_year"(?![^>]*\brequired\b)[^>]*>/', $html);
|
||||
}
|
||||
|
||||
public function testSavingOwnDetailsDelegatesToTheServiceAndRedirects(): void
|
||||
{
|
||||
$_POST = [
|
||||
'us_family_action' => 'self',
|
||||
'own_name' => 'Grace H',
|
||||
'own_birth_year' => '1984',
|
||||
'is_student' => '1',
|
||||
];
|
||||
|
||||
$this->guardians->shouldReceive('updateSelf')->once()->with(5, 'Grace H', '1984', true)->andReturn(null);
|
||||
|
||||
$captured = null;
|
||||
$this->capturingPage($captured)->maybeHandleSubmit();
|
||||
|
||||
self::assertSame('https://studio.test/family/?us_family=self', $captured);
|
||||
}
|
||||
|
||||
/** An unticked checkbox is simply absent from the post — that is the "no". */
|
||||
public function testAnAbsentStudentBoxSavesTheAccountAsGuardianOnly(): void
|
||||
{
|
||||
$_POST = [
|
||||
'us_family_action' => 'self',
|
||||
'own_name' => 'Grace H',
|
||||
'own_birth_year' => '',
|
||||
];
|
||||
|
||||
$this->guardians->shouldReceive('updateSelf')->once()->with(5, 'Grace H', '', false)->andReturn(null);
|
||||
|
||||
$captured = null;
|
||||
$this->capturingPage($captured)->maybeHandleSubmit();
|
||||
|
||||
self::assertSame('https://studio.test/family/?us_family=self', $captured);
|
||||
}
|
||||
|
||||
public function testOwnDetailsRefusalIsShownRatherThanRedirected(): void
|
||||
{
|
||||
$_POST = ['us_family_action' => 'self', 'own_name' => 'Grace', 'is_student' => '1'];
|
||||
|
||||
$this->guardians->shouldReceive('updateSelf')->once()->andReturn(
|
||||
new \WP_Error('missing_birth_year', 'Please give your birth year.')
|
||||
);
|
||||
|
||||
$captured = null;
|
||||
$page = $this->capturingPage($captured);
|
||||
$page->shouldNotReceive('redirect');
|
||||
|
||||
$page->maybeHandleSubmit();
|
||||
|
||||
self::assertNull($captured);
|
||||
}
|
||||
|
||||
public function testAddCreatesTheChildRecordsItsAnswersAndRedirects(): void
|
||||
{
|
||||
$_POST = [
|
||||
@@ -158,6 +281,71 @@ class FamilyPageTest extends TestCase
|
||||
self::assertNull($captured);
|
||||
}
|
||||
|
||||
/**
|
||||
* This screen only ever adds a student, so the students' required-ness is the
|
||||
* one that applies: a question required of the account holder alone must not
|
||||
* stop a guardian adding a child.
|
||||
*/
|
||||
public function testAddIsNotBlockedByAQuestionRequiredOnlyOfTheAccountHolder(): void
|
||||
{
|
||||
$_POST = [
|
||||
'us_family_action' => 'add',
|
||||
'child_name' => 'Ada',
|
||||
'child_birth_year' => '2015',
|
||||
'us_answers' => [7 => ' '],
|
||||
];
|
||||
|
||||
$question = new Question(
|
||||
offeringId: null,
|
||||
label: 'Instrument',
|
||||
isRequired: true,
|
||||
scope: Question::SCOPE_ACCOUNT,
|
||||
isRequiredChild: false,
|
||||
id: 7
|
||||
);
|
||||
|
||||
$this->questions->shouldReceive('findByScope')->once()->andReturn([$question]);
|
||||
$this->guardians->shouldReceive('createChild')->once()->andReturn(42);
|
||||
|
||||
// Nothing was typed, so nothing is stored — but the add went through.
|
||||
$this->answers->shouldNotReceive('insert');
|
||||
|
||||
$captured = null;
|
||||
$this->capturingPage($captured)->maybeHandleSubmit();
|
||||
|
||||
self::assertSame('https://studio.test/family/?us_family=added', $captured);
|
||||
}
|
||||
|
||||
public function testAddIsBlockedByAQuestionRequiredOnlyOfTheStudents(): void
|
||||
{
|
||||
$_POST = [
|
||||
'us_family_action' => 'add',
|
||||
'child_name' => 'Ada',
|
||||
'child_birth_year' => '2015',
|
||||
'us_answers' => [7 => ''],
|
||||
];
|
||||
|
||||
$question = new Question(
|
||||
offeringId: null,
|
||||
label: 'Instrument',
|
||||
isRequired: false,
|
||||
scope: Question::SCOPE_ACCOUNT,
|
||||
isRequiredChild: true,
|
||||
id: 7
|
||||
);
|
||||
|
||||
$this->questions->shouldReceive('findByScope')->once()->andReturn([$question]);
|
||||
$this->guardians->shouldNotReceive('createChild');
|
||||
|
||||
$captured = null;
|
||||
$page = $this->capturingPage($captured);
|
||||
$page->shouldNotReceive('redirect');
|
||||
|
||||
$page->maybeHandleSubmit();
|
||||
|
||||
self::assertNull($captured);
|
||||
}
|
||||
|
||||
public function testAddSurfacesAServiceErrorInsteadOfRedirecting(): void
|
||||
{
|
||||
$_POST = ['us_family_action' => 'add', 'child_name' => ''];
|
||||
@@ -270,6 +458,7 @@ class FamilyPageTest extends TestCase
|
||||
{
|
||||
$_GET = ['us_family' => 'added'];
|
||||
|
||||
$this->expectAccountHolder();
|
||||
$this->guardians->shouldReceive('children')->andReturn([]);
|
||||
$this->questions->shouldReceive('findByScope')->andReturn([]);
|
||||
|
||||
|
||||
@@ -321,6 +321,87 @@ class GuardianServiceTest extends TestCase
|
||||
self::assertSame('2015', $this->meta[42][GuardianService::META_BIRTH_YEAR]);
|
||||
}
|
||||
|
||||
public function testUpdateSelfRenamesAndStoresTheBirthYear(): void
|
||||
{
|
||||
$this->meta[5][GuardianService::META_GUARDIAN_ONLY] = '1';
|
||||
|
||||
Functions\expect('wp_update_user')
|
||||
->once()
|
||||
->with(['ID' => 5, 'display_name' => 'Grace H', 'nickname' => 'Grace H'])
|
||||
->andReturn(5);
|
||||
|
||||
self::assertNull($this->service->updateSelf(5, 'Grace H', '1984', true));
|
||||
|
||||
self::assertSame('1984', $this->meta[5][GuardianService::META_BIRTH_YEAR]);
|
||||
// Saying they take lessons makes them a bookable student again.
|
||||
self::assertFalse(GuardianService::isGuardianOnly(5));
|
||||
}
|
||||
|
||||
public function testUpdateSelfMarksTheAccountGuardianOnly(): void
|
||||
{
|
||||
Functions\when('wp_update_user')->justReturn(5);
|
||||
|
||||
self::assertNull($this->service->updateSelf(5, 'Grace H', '', false));
|
||||
|
||||
self::assertSame('1', $this->meta[5][GuardianService::META_GUARDIAN_ONLY]);
|
||||
}
|
||||
|
||||
/**
|
||||
* "I only book for other people" says who books, not "forget my birth year" —
|
||||
* ticking the box back on should not have cost them what was on file.
|
||||
*/
|
||||
public function testUpdateSelfKeepsAStoredBirthYearWhenTheyAreNoLongerAStudent(): void
|
||||
{
|
||||
$this->meta[5][GuardianService::META_BIRTH_YEAR] = '1984';
|
||||
|
||||
Functions\when('wp_update_user')->justReturn(5);
|
||||
|
||||
self::assertNull($this->service->updateSelf(5, 'Grace H', '', false));
|
||||
|
||||
self::assertSame('1984', $this->meta[5][GuardianService::META_BIRTH_YEAR]);
|
||||
}
|
||||
|
||||
public function testUpdateSelfRejectsABlankName(): void
|
||||
{
|
||||
Functions\expect('wp_update_user')->never();
|
||||
|
||||
self::assertInstanceOf(\WP_Error::class, $this->service->updateSelf(5, ' ', '1984', true));
|
||||
}
|
||||
|
||||
/**
|
||||
* The browser cannot enforce the year conditionally, so the server is the
|
||||
* only thing standing between a student and a nonsense age on their record.
|
||||
*
|
||||
* @dataProvider unusableBirthYears
|
||||
*/
|
||||
public function testUpdateSelfRefusesAnUnusableBirthYearFromAStudent(string $submitted): void
|
||||
{
|
||||
Functions\expect('wp_update_user')->never();
|
||||
|
||||
self::assertInstanceOf(\WP_Error::class, $this->service->updateSelf(5, 'Grace H', $submitted, true));
|
||||
}
|
||||
|
||||
public function testAccountHolderReportsTheirOwnDetails(): void
|
||||
{
|
||||
$this->meta[5][GuardianService::META_BIRTH_YEAR] = '1984';
|
||||
|
||||
Functions\when('get_userdata')->justReturn($this->user(5, 'Grace', 'Hopper', email: '[email protected]'));
|
||||
|
||||
self::assertSame(
|
||||
['name' => 'Grace Hopper', 'email' => '[email protected]', 'birth_year' => '1984', 'is_student' => true],
|
||||
$this->service->accountHolder(5)
|
||||
);
|
||||
}
|
||||
|
||||
public function testAccountHolderReportsAGuardianOnlyAccountAsNotAStudent(): void
|
||||
{
|
||||
$this->meta[5][GuardianService::META_GUARDIAN_ONLY] = '1';
|
||||
|
||||
Functions\when('get_userdata')->justReturn($this->user(5, 'Grace', 'Hopper', email: '[email protected]'));
|
||||
|
||||
self::assertFalse($this->service->accountHolder(5)['is_student']);
|
||||
}
|
||||
|
||||
public function testRemoveChildUnlinksAndDeletesAChildWithNoHistory(): void
|
||||
{
|
||||
$this->guardians->shouldReceive('isGuardianOf')->with(5, 42)->andReturn(true);
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Tests\Unit\Registration;
|
||||
|
||||
use Unsupervised\Schedular\Registration\Question;
|
||||
use Unsupervised\Schedular\Registration\QuestionField;
|
||||
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
||||
|
||||
class QuestionFieldTest extends TestCase
|
||||
{
|
||||
public function testRendersATextInputNamedAndLabelledAsAsked(): void
|
||||
{
|
||||
$html = QuestionField::render(new Question(7, 'Your level?', id: 3), 'us_answers[3]', 'us-q-3');
|
||||
|
||||
self::assertStringContainsString('<label for="us-q-3">Your level?</label>', $html);
|
||||
self::assertStringContainsString('<input type="text" name="us_answers[3]" id="us-q-3">', $html);
|
||||
}
|
||||
|
||||
public function testARequiredQuestionIsMarkedAndEnforced(): void
|
||||
{
|
||||
$question = new Question(7, 'Your level?', isRequired: true, id: 3);
|
||||
|
||||
$html = QuestionField::render($question, 'us_answers[3]', 'us-q-3');
|
||||
|
||||
self::assertStringContainsString('us-required', $html);
|
||||
self::assertStringContainsString(' required', $html);
|
||||
}
|
||||
|
||||
/**
|
||||
* A block that may not apply at all keeps the marker and drops the attribute,
|
||||
* so the browser cannot refuse a submit over a field that is out of play.
|
||||
*/
|
||||
public function testNotEnforcingRequiredKeepsTheMarkerButDropsTheAttribute(): void
|
||||
{
|
||||
$question = new Question(7, 'Your level?', isRequired: true, id: 3);
|
||||
|
||||
$html = QuestionField::render($question, 'us_answers[3]', 'us-q-3', enforceRequired: false);
|
||||
|
||||
self::assertStringContainsString('us-required', $html);
|
||||
self::assertStringNotContainsString(' required>', $html);
|
||||
}
|
||||
|
||||
/**
|
||||
* Which of the question's two required flags applies depends on whose block
|
||||
* this is, and only the caller knows that.
|
||||
*/
|
||||
public function testTheCallerCanOverrideWhichRequiredFlagApplies(): void
|
||||
{
|
||||
$question = new Question(
|
||||
null,
|
||||
'Previous experience',
|
||||
scope: Question::SCOPE_ACCOUNT,
|
||||
isRequired: false,
|
||||
isRequiredChild: true,
|
||||
id: 3
|
||||
);
|
||||
|
||||
$forSelf = QuestionField::render($question, 'us_answers[3]', 'us-q-3', isRequired: $question->isRequiredForSelf());
|
||||
$forChild = QuestionField::render($question, 'children[0][answers][3]', 'us-child-0-q-3', isRequired: $question->isRequiredForChild());
|
||||
|
||||
self::assertStringNotContainsString('us-required', $forSelf);
|
||||
self::assertStringNotContainsString(' required', $forSelf);
|
||||
|
||||
self::assertStringContainsString('us-required', $forChild);
|
||||
self::assertStringContainsString(' required', $forChild);
|
||||
}
|
||||
|
||||
public function testASelectRendersItsOptionsBehindAnEmptyChoice(): void
|
||||
{
|
||||
$question = new Question(
|
||||
7,
|
||||
'Pick a level',
|
||||
fieldType: Question::FIELD_SELECT,
|
||||
options: ['Beginner', 'Advanced'],
|
||||
id: 3
|
||||
);
|
||||
|
||||
$html = QuestionField::render($question, 'us_answers[3]', 'us-q-3');
|
||||
|
||||
self::assertStringContainsString('<select name="us_answers[3]" id="us-q-3">', $html);
|
||||
self::assertStringContainsString('<option value="Beginner">Beginner</option>', $html);
|
||||
self::assertStringContainsString('<option value="Advanced">Advanced</option>', $html);
|
||||
}
|
||||
}
|
||||
@@ -156,6 +156,59 @@ class QuestionRepositoryTest extends TestCase
|
||||
self::assertSame(30, $this->repo->insert($question));
|
||||
}
|
||||
|
||||
public function testInsertStoresAudienceAndTheStudentsRequiredFlag(): void
|
||||
{
|
||||
Functions\expect('current_time')->andReturn('2026-04-01 12:00:00');
|
||||
|
||||
$this->db->shouldReceive('insert')
|
||||
->once()
|
||||
->with(
|
||||
'wp_us_questions',
|
||||
Mockery::on(static function (array $data): bool {
|
||||
return $data['audience'] === Question::AUDIENCE_CHILD
|
||||
&& $data['is_required'] === 0
|
||||
&& $data['is_required_child'] === 1;
|
||||
}),
|
||||
// One placeholder per column, in the same order.
|
||||
Mockery::on(static fn (array $format): bool => count($format) === 11)
|
||||
);
|
||||
|
||||
$this->db->insert_id = 31;
|
||||
|
||||
$question = new Question(
|
||||
null,
|
||||
'School and grade',
|
||||
scope: Question::SCOPE_ACCOUNT,
|
||||
audience: Question::AUDIENCE_CHILD,
|
||||
isRequiredChild: true
|
||||
);
|
||||
|
||||
self::assertSame(31, $this->repo->insert($question));
|
||||
}
|
||||
|
||||
public function testBackfillChildRequiredCopiesTheOldRequiredFlagAcross(): void
|
||||
{
|
||||
$this->db->shouldReceive('prepare')
|
||||
->once()
|
||||
->with(Mockery::pattern('/UPDATE %i SET is_required_child = 1 WHERE is_required = 1/'), 'wp_us_questions')
|
||||
->andReturn('UPDATE `wp_us_questions` SET is_required_child = 1 WHERE is_required = 1');
|
||||
|
||||
$this->db->shouldReceive('query')
|
||||
->once()
|
||||
->with('UPDATE `wp_us_questions` SET is_required_child = 1 WHERE is_required = 1')
|
||||
->andReturn(2);
|
||||
|
||||
self::assertTrue($this->repo->backfillChildRequired());
|
||||
}
|
||||
|
||||
public function testBackfillChildRequiredReportsFailureWhenQueryFails(): void
|
||||
{
|
||||
$this->db->shouldReceive('prepare')->once()->andReturn('UPDATE ...');
|
||||
$this->db->shouldReceive('query')->once()->andReturn(false);
|
||||
|
||||
self::assertFalse($this->repo->backfillChildRequired());
|
||||
}
|
||||
|
||||
public function testFindByScopeActiveOnlyPreparesQuery(): void
|
||||
{
|
||||
$this->db->shouldReceive('prepare')
|
||||
|
||||
@@ -101,11 +101,112 @@ class QuestionTest extends TestCase
|
||||
$question = new Question(7, 'Label', Question::FIELD_TEXT, id: 9);
|
||||
$arr = $question->toArray();
|
||||
|
||||
foreach (['id', 'offering_id', 'scope', 'label', 'field_type', 'options', 'is_required', 'sort_order', 'is_active'] as $key) {
|
||||
foreach (['id', 'offering_id', 'scope', 'label', 'field_type', 'options', 'audience', 'is_required', 'is_required_child', 'sort_order', 'is_active'] as $key) {
|
||||
self::assertArrayHasKey($key, $arr);
|
||||
}
|
||||
}
|
||||
|
||||
public function testDefaultsToBeingAskedOfEveryoneAndRequiredOfNobody(): void
|
||||
{
|
||||
$question = new Question(null, 'Instrument', scope: Question::SCOPE_ACCOUNT);
|
||||
|
||||
self::assertSame(Question::AUDIENCE_ALL, $question->audience);
|
||||
self::assertTrue($question->askedOfSelf());
|
||||
self::assertFalse($question->isRequiredForSelf());
|
||||
self::assertFalse($question->isRequiredForChild());
|
||||
}
|
||||
|
||||
public function testAChildAudienceQuestionIsNeverAskedOfTheAccountHolder(): void
|
||||
{
|
||||
$question = new Question(
|
||||
null,
|
||||
'School and grade',
|
||||
scope: Question::SCOPE_ACCOUNT,
|
||||
audience: Question::AUDIENCE_CHILD,
|
||||
isRequired: true,
|
||||
isRequiredChild: true
|
||||
);
|
||||
|
||||
self::assertFalse($question->askedOfSelf());
|
||||
|
||||
// Required-ness cannot outlive the audience: a question the account
|
||||
// holder is never shown must never be one they are held to.
|
||||
self::assertFalse($question->isRequiredForSelf());
|
||||
self::assertTrue($question->isRequiredForChild());
|
||||
}
|
||||
|
||||
public function testAQuestionCanBeOptionalForYouAndRequiredForYourStudents(): void
|
||||
{
|
||||
$question = new Question(
|
||||
null,
|
||||
'Previous experience',
|
||||
scope: Question::SCOPE_ACCOUNT,
|
||||
isRequired: false,
|
||||
isRequiredChild: true
|
||||
);
|
||||
|
||||
self::assertTrue($question->askedOfSelf());
|
||||
self::assertFalse($question->isRequiredForSelf());
|
||||
self::assertTrue($question->isRequiredForChild());
|
||||
}
|
||||
|
||||
public function testFromRowReadsAudienceAndTheStudentsRequiredFlag(): void
|
||||
{
|
||||
$row = (object) [
|
||||
'id' => '6',
|
||||
'offering_id' => null,
|
||||
'scope' => Question::SCOPE_ACCOUNT,
|
||||
'label' => 'School and grade',
|
||||
'field_type' => Question::FIELD_TEXT,
|
||||
'options' => null,
|
||||
'audience' => Question::AUDIENCE_CHILD,
|
||||
'is_required' => '0',
|
||||
'is_required_child' => '1',
|
||||
'sort_order' => '0',
|
||||
'is_active' => '1',
|
||||
];
|
||||
|
||||
$question = Question::fromRow($row);
|
||||
|
||||
self::assertSame(Question::AUDIENCE_CHILD, $question->audience);
|
||||
self::assertFalse($question->askedOfSelf());
|
||||
self::assertTrue($question->isRequiredForChild());
|
||||
}
|
||||
|
||||
/**
|
||||
* A row read before dbDelta has added the columns — or one carrying a value
|
||||
* no longer recognised — falls back to the behaviour every question had
|
||||
* before the distinction existed: asked of everyone.
|
||||
*/
|
||||
public function testFromRowFallsBackToEveryoneWhenAudienceIsMissingOrUnknown(): void
|
||||
{
|
||||
$base = [
|
||||
'id' => '7',
|
||||
'offering_id' => null,
|
||||
'scope' => Question::SCOPE_ACCOUNT,
|
||||
'label' => 'Instrument',
|
||||
'field_type' => Question::FIELD_TEXT,
|
||||
'options' => null,
|
||||
'is_required' => '1',
|
||||
'sort_order' => '0',
|
||||
'is_active' => '1',
|
||||
];
|
||||
|
||||
$missing = Question::fromRow((object) $base);
|
||||
$unknown = Question::fromRow((object) ($base + ['audience' => 'grown-ups']));
|
||||
|
||||
self::assertSame(Question::AUDIENCE_ALL, $missing->audience);
|
||||
self::assertTrue($missing->isRequiredForSelf());
|
||||
self::assertFalse($missing->isRequiredForChild());
|
||||
self::assertSame(Question::AUDIENCE_ALL, $unknown->audience);
|
||||
}
|
||||
|
||||
public function testValidAudienceConstants(): void
|
||||
{
|
||||
self::assertContains(Question::AUDIENCE_ALL, Question::VALID_AUDIENCES);
|
||||
self::assertContains(Question::AUDIENCE_CHILD, Question::VALID_AUDIENCES);
|
||||
}
|
||||
|
||||
public function testValidFieldTypeConstants(): void
|
||||
{
|
||||
self::assertContains(Question::FIELD_TEXT, Question::VALID_FIELD_TYPES);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Plugin Name: Unsupervised Scheduler
|
||||
* Plugin URI: https://git.unsupervised.ca/Unsupervised/unsupervised-scheduler
|
||||
* Description: Instructor/student lesson scheduling for WordPress.
|
||||
* Version: 1.4.1
|
||||
* Version: 1.5.0
|
||||
* Requires at least: 6.2
|
||||
* Requires PHP: 8.1
|
||||
* Author: Unsupervised
|
||||
@@ -21,7 +21,7 @@ if (! defined('ABSPATH')) {
|
||||
exit;
|
||||
}
|
||||
|
||||
define('USC_VERSION', '1.4.1');
|
||||
define('USC_VERSION', '1.5.0');
|
||||
define('USC_PLUGIN_FILE', __FILE__);
|
||||
define('USC_PLUGIN_DIR', plugin_dir_path(__FILE__));
|
||||
define('USC_PLUGIN_URL', plugin_dir_url(__FILE__));
|
||||
|
||||
Reference in New Issue
Block a user