*/ public const VALID_FIELD_TYPES = [ self::FIELD_TEXT, self::FIELD_TEXTAREA, self::FIELD_SELECT, self::FIELD_CHECKBOX, ]; /** * All valid scopes. * * @var list */ public const VALID_SCOPES = [ self::SCOPE_OFFERING, self::SCOPE_ACCOUNT, ]; /** * All valid audiences. * * @var list */ 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|null $options Choices for a `select` field. */ public function __construct( public readonly ?int $offeringId, public readonly string $label, public readonly string $fieldType = self::FIELD_TEXT, public readonly ?array $options = null, public readonly bool $isRequired = false, 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 ) { $decoded = json_decode( Val::string( $row->options ), true ); $options = is_array( $decoded ) ? array_values( array_map( static fn( mixed $v ): string => Val::string( $v ), $decoded ) ) : 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 ), fieldType: Val::string( $row->field_type ), options: $options, isRequired: Val::bool( $row->is_required ), 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 ), ); } /** * Returns a plain array representation of the question. * * @return array */ public function toArray(): array { return [ 'id' => $this->id, 'offering_id' => $this->offeringId, 'scope' => $this->scope, '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, ]; } }