Skip to content

Commit 8c2aab2

Browse files
committed
Merge branch 'release/0.8.74'
2 parents 46e41b2 + ae8f7e8 commit 8c2aab2

15 files changed

Lines changed: 674 additions & 17 deletions

File tree

.version.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,6 @@
22
"strategy": "semver",
33
"major": 0,
44
"minor": 8,
5-
"patch": 73,
5+
"patch": 74,
66
"build": 0
77
}

resources/config/neuron.yaml.example

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -216,12 +216,26 @@ maintenance:
216216
# Field properties:
217217
# name input name (also the stored payload key)
218218
# label display label
219-
# type text | email | tel | textarea | select | checkbox
220-
# required true|false
221-
# options list of allowed values (select only)
222-
# rules optional: length: { min, max }, pattern: "/regex/"
219+
# type text | email | tel | textarea | select | checkbox | checkboxes | multiselect
220+
# required true|false (for checkboxes/multiselect: at least one selection)
221+
# options list of allowed values (select / checkboxes / multiselect)
222+
# each option may be a plain string or { value, label }
223+
# groups checkboxes/multiselect only: list of { label, options } to
224+
# render grouped choices (values must be unique across groups)
225+
# rules optional: length: { min, max }, pattern: "/regex/",
226+
# count: { min, max } (checkboxes/multiselect selection count)
223227
# reply_to true -> this field supplies the reply-to email address
224228
# sender_name true -> this field supplies the reply-to display name
229+
#
230+
# Example multi-select (grouped checkboxes):
231+
# - name: opportunities
232+
# label: "Volunteer opportunities"
233+
# type: checkboxes
234+
# groups:
235+
# - label: "Sessions"
236+
# options:
237+
# - { value: judge, label: "Judge" }
238+
# - { value: jury, label: "Jury Monitor" }
225239
contact:
226240
default_form: general # Form rendered by a bare [contact] shortcode
227241
forms:

resources/config/services.yaml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,9 @@ services:
8080
Neuron\Cms\Repositories\DatabaseRevisionRepository:
8181
type: autowire
8282

83+
Neuron\Cms\Repositories\DatabaseContactSubmissionRepository:
84+
type: autowire
85+
8386

8487
# ============================================================================
8588
# REPOSITORIES - Interface Bindings
@@ -129,6 +132,10 @@ services:
129132
type: alias
130133
target: Neuron\Cms\Repositories\DatabaseRevisionRepository
131134

135+
Neuron\Cms\Repositories\IContactSubmissionRepository:
136+
type: alias
137+
target: Neuron\Cms\Repositories\DatabaseContactSubmissionRepository
138+
132139

133140
# ============================================================================
134141
# AUTHENTICATION SERVICES

resources/views/admin/contact_submissions/show.php

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,32 @@
11
<?php
2-
$labelFor = static function( string $name ) use ( $fields ): string {
2+
$fieldFor = static function( string $name ) use ( $fields ): ?array {
33
foreach( ( $fields ?? [] ) as $field )
44
{
55
if( ( $field['name'] ?? null ) === $name )
66
{
7-
return $field['label'] ?? $name;
7+
return $field;
88
}
99
}
10-
return $name;
10+
return null;
11+
};
12+
13+
$labelFor = static function( string $name ) use ( $fieldFor ): string {
14+
$field = $fieldFor( $name );
15+
return $field['label'] ?? $name;
16+
};
17+
18+
$displayValue = static function( string $name, mixed $value ) use ( $fieldFor ): string {
19+
if( is_array( $value ) )
20+
{
21+
$field = $fieldFor( $name );
22+
$labels = $field !== null
23+
? \Neuron\Cms\Services\Contact\FieldOptions::labelsFor( $field, $value )
24+
: array_map( 'strval', $value );
25+
26+
return implode( "\n", $labels );
27+
}
28+
29+
return (string) $value;
1130
};
1231

1332
// Order values by configured fields first, then any extra payload keys.
@@ -94,7 +113,7 @@
94113
<dl class="row mb-0">
95114
<?php foreach( $orderedKeys as $name ): ?>
96115
<dt class="col-sm-3"><?= htmlspecialchars( $labelFor( $name ) ) ?></dt>
97-
<dd class="col-sm-9" style="white-space: pre-wrap;"><?= htmlspecialchars( (string) ( $payload[ $name ] ?? '' ) ) ?></dd>
116+
<dd class="col-sm-9" style="white-space: pre-wrap;"><?= htmlspecialchars( $displayValue( $name, $payload[ $name ] ?? '' ) ) ?></dd>
98117
<?php endforeach; ?>
99118
</dl>
100119
<?php endif; ?>

resources/views/emails/contact.php

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,11 @@
4848
}
4949
$label = $field['label'] ?? $name;
5050
$value = $values[ $name ] ?? '';
51-
if( !is_scalar( $value ) )
51+
if( is_array( $value ) )
52+
{
53+
$value = implode( "\n", \Neuron\Cms\Services\Contact\FieldOptions::labelsFor( $field, $value ) );
54+
}
55+
elseif( !is_scalar( $value ) )
5256
{
5357
$value = json_encode( $value );
5458
}

src/Cms/Controllers/Contact.php

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
use Neuron\Cms\Services\Auth\CsrfToken;
88
use Neuron\Cms\Services\Contact\ContactFormValidator;
99
use Neuron\Cms\Services\Contact\ContactService;
10+
use Neuron\Cms\Services\Contact\FieldOptions;
1011
use Neuron\Cms\Services\Widget\ContactFormWidget;
1112
use Neuron\Data\Settings\SettingManager;
1213
use Neuron\Log\Log;
@@ -197,20 +198,69 @@ private function collectValues( array $fields, Request $request ): array
197198
continue;
198199
}
199200

200-
if( ( $field['type'] ?? 'text' ) === 'checkbox' )
201+
$type = $field['type'] ?? 'text';
202+
203+
if( $type === 'checkbox' )
201204
{
202205
$raw = $request->post( $name, '' );
203206
$values[ $name ] = ( $raw === 'on' || $raw === '1' || $raw === 1 || $raw === true ) ? '1' : '';
204207
continue;
205208
}
206209

210+
if( $type === 'checkboxes' || $type === 'multiselect' )
211+
{
212+
$values[ $name ] = $this->collectMultiValues( $field, $name, $request );
213+
continue;
214+
}
215+
207216
$raw = $request->post( $name, '' );
208217
$values[ $name ] = is_string( $raw ) ? trim( $raw ) : $raw;
209218
}
210219

211220
return $values;
212221
}
213222

223+
/**
224+
* Collect and sanitize the selected values for a multi-select field.
225+
*
226+
* Only values present in the field's configured option set are kept, so a
227+
* crafted request cannot inject arbitrary selections into storage/email.
228+
*
229+
* @param array $field
230+
* @param string $name
231+
* @param Request $request
232+
* @return array<int, string>
233+
*/
234+
private function collectMultiValues( array $field, string $name, Request $request ): array
235+
{
236+
$raw = $request->post( $name, [] );
237+
238+
if( !is_array( $raw ) )
239+
{
240+
$raw = ( $raw === '' || $raw === null ) ? [] : [ $raw ];
241+
}
242+
243+
$allowed = FieldOptions::allowedValues( $field );
244+
$selected = [];
245+
246+
foreach( $raw as $value )
247+
{
248+
if( !is_scalar( $value ) )
249+
{
250+
continue;
251+
}
252+
253+
$value = trim( (string) $value );
254+
255+
if( $value !== '' && in_array( $value, $allowed, true ) && !in_array( $value, $selected, true ) )
256+
{
257+
$selected[] = $value;
258+
}
259+
}
260+
261+
return $selected;
262+
}
263+
214264
/**
215265
* Build the thank-you message for a form.
216266
*

src/Cms/Services/Contact/ContactFormValidator.php

Lines changed: 70 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,35 @@ public function validate( array $fieldDefs, array $values ): array
4141

4242
$label = $field['label'] ?? $name;
4343
$required = (bool) ( $field['required'] ?? false );
44+
$type = $field['type'] ?? 'text';
4445
$raw = $values[ $name ] ?? null;
46+
47+
if( $type === 'checkboxes' || $type === 'multiselect' )
48+
{
49+
$selected = is_array( $raw )
50+
? $raw
51+
: ( ( $raw === null || $raw === '' ) ? [] : [ $raw ] );
52+
53+
if( empty( $selected ) )
54+
{
55+
if( $required )
56+
{
57+
$errors[ $name ] = "{$label} is required.";
58+
}
59+
60+
continue;
61+
}
62+
63+
$error = $this->validateMultiple( $field, $label, $selected );
64+
65+
if( $error !== null )
66+
{
67+
$errors[ $name ] = $error;
68+
}
69+
70+
continue;
71+
}
72+
4573
$value = is_string( $raw ) ? trim( $raw ) : $raw;
4674
$isEmpty = ( $value === null || $value === '' || $value === [] );
4775

@@ -90,11 +118,11 @@ private function validateField( array $field, string $label, string $value ): ?s
90118
return "{$label} must be a valid phone number.";
91119
}
92120

93-
if( $type === 'select' )
121+
if( $type === 'select' || $type === 'radio' )
94122
{
95-
$options = $field['options'] ?? [];
123+
$allowed = FieldOptions::allowedValues( $field );
96124

97-
if( !empty( $options ) && !new IsInSet( $options )->isValid( $value ) )
125+
if( !empty( $allowed ) && !new IsInSet( $allowed )->isValid( $value ) )
98126
{
99127
return "{$label} is not a valid selection.";
100128
}
@@ -119,6 +147,45 @@ private function validateField( array $field, string $label, string $value ): ?s
119147
return null;
120148
}
121149

150+
/**
151+
* Validate the selected values of a multi-select (checkboxes/multiselect)
152+
* field: every selection must belong to the configured option set, and an
153+
* optional count rule (rules.count.min/max) is enforced.
154+
*
155+
* @param array $field Field definition
156+
* @param string $label Display label
157+
* @param array $selected Selected values
158+
* @return string|null Error message, or null when valid
159+
*/
160+
private function validateMultiple( array $field, string $label, array $selected ): ?string
161+
{
162+
$allowed = FieldOptions::allowedValues( $field );
163+
164+
foreach( $selected as $value )
165+
{
166+
if( !is_scalar( $value ) || !in_array( (string) $value, $allowed, true ) )
167+
{
168+
return "{$label} contains an invalid selection.";
169+
}
170+
}
171+
172+
$rules = $field['rules'] ?? [];
173+
174+
if( isset( $rules['count'] ) )
175+
{
176+
$min = (int) ( $rules['count']['min'] ?? 0 );
177+
$max = (int) ( $rules['count']['max'] ?? PHP_INT_MAX );
178+
$count = count( $selected );
179+
180+
if( $count < $min || $count > $max )
181+
{
182+
return "Please select between {$min} and {$max} options for {$label}.";
183+
}
184+
}
185+
186+
return null;
187+
}
188+
122189
/**
123190
* Lenient phone validation suitable for a public contact form.
124191
*

src/Cms/Services/Contact/ContactService.php

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -210,7 +210,17 @@ private function buildPlainBody( array $fieldDefs, array $values ): string
210210

211211
$label = $field['label'] ?? $name;
212212
$value = $values[ $name ] ?? '';
213-
$lines[] = $label . ': ' . ( is_scalar( $value ) ? (string) $value : json_encode( $value ) );
213+
214+
if( is_array( $value ) )
215+
{
216+
$value = implode( ', ', FieldOptions::labelsFor( $field, $value ) );
217+
}
218+
elseif( !is_scalar( $value ) )
219+
{
220+
$value = json_encode( $value );
221+
}
222+
223+
$lines[] = $label . ': ' . $value;
214224
}
215225

216226
return implode( "\n", $lines );

0 commit comments

Comments
 (0)