diff --git a/lib/HTML/FormFu.pm b/lib/HTML/FormFu.pm
index b94d5047..733c0069 100644
--- a/lib/HTML/FormFu.pm
+++ b/lib/HTML/FormFu.pm
@@ -111,6 +111,13 @@ has form_error_message_class => (
lazy => 1,
);
+has repeatable_max_counter => (
+ is => 'rw',
+ default => 100,
+ lazy => 1,
+ traits => ['Chained'],
+);
+
our @MULTIFORM_SHARED = ( qw(
javascript
javascript_src
@@ -1485,6 +1492,19 @@ used as the return value for L.
If L is not set, L will return true if a value for
any known fieldname was submitted.
+=head2 repeatable_max_counter
+
+Arguments: $number
+
+Default Value: C<100>
+
+The default L
+for all L elements in this
+form. Individual Repeatable elements can override this by setting their own
+L.
+
+Set to C<0> to disable clamping form-wide.
+
=head2 auto_fieldset
Arguments: 1
diff --git a/lib/HTML/FormFu/Element/Repeatable.pm b/lib/HTML/FormFu/Element/Repeatable.pm
index 1769e547..cb60b23a 100644
--- a/lib/HTML/FormFu/Element/Repeatable.pm
+++ b/lib/HTML/FormFu/Element/Repeatable.pm
@@ -14,6 +14,23 @@ use Carp qw( croak );
has counter_name => ( is => 'rw', traits => ['Chained'] );
+has max_counter => (
+ is => 'rw',
+ default => sub {
+ my $form = $_[0]->form;
+ return $form->repeatable_max_counter if $form;
+ return 100;
+ },
+ lazy => 1,
+ traits => ['Chained'],
+);
+
+has counter_clamped => (
+ is => 'ro',
+ default => 0,
+ writer => '_set_counter_clamped',
+);
+
has _original_elements => ( is => 'rw' );
has increment_field_names => (
@@ -261,6 +278,8 @@ sub process {
my $form = $self->form;
my $count = 1;
+ $self->_set_counter_clamped(0);
+
if ( defined $counter_name && defined $form->query ) {
# are we in a nested-repeatable?
@@ -279,7 +298,15 @@ sub process {
my $input = $form->query->param($counter_name);
if ( defined $input && $input =~ /^[1-9][0-9]*\z/ ) {
- $count = $input;
+ my $max = $self->max_counter;
+
+ if ( defined $max && $max > 0 && $input > $max ) {
+ $count = $max;
+ $self->_set_counter_clamped(1);
+ }
+ else {
+ $count = $input;
+ }
}
}
@@ -426,6 +453,31 @@ present on the form during L, no Processors
(Constraints, etc.) will be run on the fields, and their values will not
be returned by L or L.
+=head2 max_counter
+
+Arguments: $number
+
+Default Value: C<100>
+
+The largest repeat count that will be accepted from the L
+query parameter. A larger client-supplied value is clamped to this number.
+
+The default is inherited from L, so
+setting that on the form sets the default for all its Repeatable elements.
+
+Set to C<0> to accept any client-supplied count without clamping. Doing so
+lets a single request drive an unbounded number of element clones.
+
+Calling L from application code is not affected by this setting,
+so a form repeated once per database row is unaffected however many rows
+there are.
+
+=head2 counter_clamped
+
+Read-only boolean. True if the most recent L call
+clamped the client-supplied count to L. Reset to false at the
+start of each L call.
+
=head2 increment_field_names
Arguments: $bool
diff --git a/t/repeatable/max_counter.t b/t/repeatable/max_counter.t
new file mode 100644
index 00000000..ba51f325
--- /dev/null
+++ b/t/repeatable/max_counter.t
@@ -0,0 +1,123 @@
+use strict;
+use warnings;
+
+use Test::More tests => 13;
+
+use HTML::FormFu;
+use lib 't/lib';
+use HTMLFormFu::TestLib;
+
+# Test that max_counter defaults to 100 and clamps large values
+{
+ my $form = HTML::FormFu->new(
+ { tt_args => { INCLUDE_PATH => 'share/templates/tt/xhtml' } } );
+
+ $form->load_config_file('t/repeatable/max_counter.yml');
+
+ my $repeatable = $form->get_element( { type => 'Repeatable' } );
+
+ is( $repeatable->max_counter, 100, 'default max_counter is 100' );
+
+ $form->process( { count => 200 } );
+
+ my @blocks = @{ $repeatable->get_elements };
+ is( scalar @blocks, 100, '200 repeats clamped to 100' );
+ ok( $repeatable->counter_clamped, 'counter_clamped is true after clamping' );
+}
+
+# Test that max_counter can be set lower
+{
+ my $form = HTML::FormFu->new(
+ { tt_args => { INCLUDE_PATH => 'share/templates/tt/xhtml' } } );
+
+ $form->load_config_file('t/repeatable/max_counter.yml');
+
+ my $repeatable = $form->get_element( { type => 'Repeatable' } );
+ $repeatable->max_counter(5);
+
+ $form->process( { count => 10 } );
+
+ my @blocks = @{ $repeatable->get_elements };
+ is( scalar @blocks, 5, '10 repeats clamped to 5' );
+ ok( $repeatable->counter_clamped, 'counter_clamped is true' );
+}
+
+# Test that max_counter=0 means unlimited (not clamped)
+{
+ my $form = HTML::FormFu->new(
+ { tt_args => { INCLUDE_PATH => 'share/templates/tt/xhtml' } } );
+
+ $form->load_config_file('t/repeatable/max_counter.yml');
+
+ my $repeatable = $form->get_element( { type => 'Repeatable' } );
+ $repeatable->max_counter(0);
+
+ $form->process( { count => 3 } );
+
+ my @blocks = @{ $repeatable->get_elements };
+ is( scalar @blocks, 3, 'max_counter=0 allows 3 repeats unclamped' );
+ ok( !$repeatable->counter_clamped, 'counter_clamped is false' );
+}
+
+# Test that normal small values are unaffected
+{
+ my $form = HTML::FormFu->new(
+ { tt_args => { INCLUDE_PATH => 'share/templates/tt/xhtml' } } );
+
+ $form->load_config_file('t/repeatable/max_counter.yml');
+
+ $form->process( { count => 3 } );
+
+ my $repeatable = $form->get_element( { type => 'Repeatable' } );
+ my @blocks = @{ $repeatable->get_elements };
+ is( scalar @blocks, 3, '3 repeats below default max_counter' );
+ ok( !$repeatable->counter_clamped, 'counter_clamped is false' );
+}
+
+# Test that calling repeat() directly is unaffected by max_counter
+{
+ my $form = HTML::FormFu->new(
+ { tt_args => { INCLUDE_PATH => 'share/templates/tt/xhtml' } } );
+
+ $form->load_config_file('t/repeatable/max_counter.yml');
+
+ my $repeatable = $form->get_element( { type => 'Repeatable' } );
+ $repeatable->repeat(200);
+
+ my @blocks = @{ $repeatable->get_elements };
+ is( scalar @blocks, 200, 'direct repeat(200) unaffected by max_counter' );
+}
+
+# Test form-level repeatable_max_counter
+{
+ my $form = HTML::FormFu->new(
+ { tt_args => { INCLUDE_PATH => 'share/templates/tt/xhtml' },
+ repeatable_max_counter => 50,
+ } );
+
+ $form->load_config_file('t/repeatable/max_counter.yml');
+
+ my $repeatable = $form->get_element( { type => 'Repeatable' } );
+
+ is( $repeatable->max_counter, 50,
+ 'max_counter inherits form repeatable_max_counter' );
+
+ $form->process( { count => 200 } );
+
+ my @blocks = @{ $repeatable->get_elements };
+ is( scalar @blocks, 50, '200 clamped to form-level 50' );
+}
+
+# Test chained method works
+{
+ my $form = HTML::FormFu->new(
+ { tt_args => { INCLUDE_PATH => 'share/templates/tt/xhtml' } } );
+
+ $form->load_config_file('t/repeatable/max_counter.yml');
+
+ my $repeatable = $form->get_element( { type => 'Repeatable' } );
+
+ $repeatable->max_counter(10)->max_counter(20);
+
+ is( $repeatable->max_counter, 20, 'chained max_counter setter works' );
+}
diff --git a/t/repeatable/max_counter.yml b/t/repeatable/max_counter.yml
new file mode 100644
index 00000000..2166822b
--- /dev/null
+++ b/t/repeatable/max_counter.yml
@@ -0,0 +1,11 @@
+---
+elements:
+ - type: Hidden
+ name: count
+
+ - type: Repeatable
+ nested_name: rep
+ increment_field_names: 1
+ counter_name: count
+ elements:
+ - name: foo