├── .github ├── .kodiak.toml └── workflows │ ├── codesniffer.yml │ ├── coverage.yml │ ├── phpstan.yml │ └── tests.yml ├── LICENSE ├── codeception.yml ├── composer.json └── src ├── Buttons ├── CreateButton.php └── RemoveButton.php ├── ComponentResolver.php ├── DI └── MultiplierExtension.php ├── ISubmitter.php ├── Latte └── Extension │ ├── MultiplierExtension.php │ └── Node │ ├── MultiplierAddNode.php │ ├── MultiplierNode.php │ └── MultiplierRemoveNode.php ├── Multiplier.php └── Submitter.php /.github/.kodiak.toml: -------------------------------------------------------------------------------- 1 | version = 1 2 | 3 | [merge] 4 | automerge_label = "automerge" 5 | blacklist_title_regex = "^WIP.*" 6 | blacklist_labels = ["WIP"] 7 | method = "rebase" 8 | delete_branch_on_merge = true 9 | notify_on_conflict = true 10 | optimistic_updates = false 11 | -------------------------------------------------------------------------------- /.github/workflows/codesniffer.yml: -------------------------------------------------------------------------------- 1 | name: "Codesniffer" 2 | 3 | on: 4 | pull_request: 5 | workflow_dispatch: 6 | 7 | push: 8 | branches: ["*"] 9 | 10 | schedule: 11 | - cron: "0 8 * * 1" 12 | 13 | jobs: 14 | codesniffer: 15 | name: "Codesniffer" 16 | uses: contributte/.github/.github/workflows/codesniffer.yml@master 17 | with: 18 | php: "8.3" 19 | -------------------------------------------------------------------------------- /.github/workflows/coverage.yml: -------------------------------------------------------------------------------- 1 | name: "Coverage" 2 | 3 | on: 4 | pull_request: 5 | workflow_dispatch: 6 | 7 | push: 8 | branches: ["*"] 9 | 10 | schedule: 11 | - cron: "0 8 * * 1" 12 | 13 | jobs: 14 | coverage: 15 | name: "Nette Tester" 16 | uses: contributte/.github/.github/workflows/nette-tester-coverage-v2.yml@master 17 | with: 18 | php: "8.3" 19 | -------------------------------------------------------------------------------- /.github/workflows/phpstan.yml: -------------------------------------------------------------------------------- 1 | name: "Phpstan" 2 | 3 | on: 4 | pull_request: 5 | workflow_dispatch: 6 | 7 | push: 8 | branches: ["*"] 9 | 10 | schedule: 11 | - cron: "0 8 * * 1" 12 | 13 | jobs: 14 | phpstan: 15 | name: "Phpstan" 16 | uses: contributte/.github/.github/workflows/phpstan.yml@master 17 | with: 18 | php: "8.3" 19 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: "Nette Tester" 2 | 3 | on: 4 | pull_request: 5 | workflow_dispatch: 6 | 7 | push: 8 | branches: [ "*" ] 9 | 10 | schedule: 11 | - cron: "0 8 * * 1" 12 | 13 | jobs: 14 | test84: 15 | name: "Nette Tester" 16 | uses: contributte/.github/.github/workflows/nette-tester.yml@master 17 | with: 18 | php: "8.4" 19 | 20 | test83: 21 | name: "Nette Tester" 22 | uses: contributte/.github/.github/workflows/nette-tester.yml@master 23 | with: 24 | php: "8.3" 25 | 26 | test82: 27 | name: "Nette Tester" 28 | uses: contributte/.github/.github/workflows/nette-tester.yml@master 29 | with: 30 | php: "8.2" 31 | 32 | test81: 33 | name: "Nette Tester" 34 | uses: contributte/.github/.github/workflows/nette-tester.yml@master 35 | with: 36 | php: "8.1" 37 | 38 | testlower: 39 | name: "Nette Tester" 40 | uses: contributte/.github/.github/workflows/nette-tester.yml@master 41 | with: 42 | php: "8.1" 43 | composer: "composer update --no-interaction --no-progress --prefer-dist --prefer-stable --prefer-lowest" 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2015 Contributte 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /codeception.yml: -------------------------------------------------------------------------------- 1 | actor: Tester 2 | 3 | paths: 4 | # where the tests stored 5 | tests: tests 6 | 7 | # directory for fixture data 8 | data: tests/Support/Data 9 | 10 | # directory for support code 11 | support: tests/Support 12 | 13 | # directory for output 14 | output: tests/_output 15 | 16 | settings: 17 | log: true 18 | 19 | coverage: 20 | enabled: true 21 | include: 22 | - src/** 23 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "contributte/forms-multiplier", 3 | "type": "library", 4 | "license": "MIT", 5 | "description": "Multiplier for nette forms", 6 | "keywords": [ 7 | "nette", 8 | "contributte", 9 | "forms", 10 | "multiplier" 11 | ], 12 | "require": { 13 | "php": ">=8.1", 14 | "nette/forms": "^3.1.12" 15 | }, 16 | "require-dev": { 17 | "codeception/codeception": "^5.0", 18 | "codeception/module-asserts": "^3.0", 19 | "codeception/module-phpbrowser": "^3.0", 20 | "nette/application": "^3.1.11", 21 | "nette/di": "^3.1.0", 22 | "latte/latte": "^3.0.0", 23 | "contributte/qa": "^0.3", 24 | "contributte/phpstan": "^0.2", 25 | "webchemistry/testing-helpers": "^4.0.0" 26 | }, 27 | "conflict": { 28 | "latte/latte": "<3.0.0", 29 | "nette/component-model": "<3.1.0" 30 | }, 31 | "autoload": { 32 | "psr-4": { 33 | "Contributte\\FormMultiplier\\": "src" 34 | } 35 | }, 36 | "autoload-dev": { 37 | "psr-4": { 38 | "Tests\\": "tests" 39 | } 40 | }, 41 | "prefer-stable": true, 42 | "minimum-stability": "dev", 43 | "config": { 44 | "sort-packages": true, 45 | "allow-plugins": { 46 | "dealerdirect/phpcodesniffer-composer-installer": true 47 | } 48 | }, 49 | "extra": { 50 | "branch-alias": { 51 | "dev-master": "4.1.x-dev" 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Buttons/CreateButton.php: -------------------------------------------------------------------------------- 1 | caption = $caption; 30 | $this->copyCount = $copyCount; 31 | } 32 | 33 | public function addOnCreateCallback(callable $onCreate): self 34 | { 35 | $this->onCreate[] = $onCreate; 36 | 37 | return $this; 38 | } 39 | 40 | public function setNoValidate(): self 41 | { 42 | $this->setValidationScope([]); 43 | 44 | return $this; 45 | } 46 | 47 | public function addClass(string $class): self 48 | { 49 | $this->classes[] = $class; 50 | 51 | return $this; 52 | } 53 | 54 | /** 55 | * @param mixed[]|null $validationScope 56 | */ 57 | public function setValidationScope(?array $validationScope): self 58 | { 59 | $this->validationScope = $validationScope; 60 | 61 | return $this; 62 | } 63 | 64 | public function getComponentName(): string 65 | { 66 | return Multiplier::SUBMIT_CREATE_NAME . ($this->copyCount === 1 ? '' : $this->copyCount); 67 | } 68 | 69 | public function getCopyCount(): int 70 | { 71 | return $this->copyCount; 72 | } 73 | 74 | public function create(Multiplier $multiplier): Submitter 75 | { 76 | $button = new Submitter($this->caption, $this->copyCount); 77 | 78 | $button->setHtmlAttribute('class', implode(' ', $this->classes)); 79 | $button->setValidationScope($this->validationScope ?? [$multiplier]) 80 | ->setOmitted(); 81 | 82 | foreach ($this->onCreate as $callback) { 83 | $callback($button); 84 | } 85 | 86 | $button->onClick[] = [$multiplier, 'resetFormEvents']; 87 | 88 | return $button; 89 | } 90 | 91 | } 92 | -------------------------------------------------------------------------------- /src/Buttons/RemoveButton.php: -------------------------------------------------------------------------------- 1 | caption = $caption; 26 | } 27 | 28 | public function addOnCreateCallback(callable $onCreate): self 29 | { 30 | $this->onCreate[] = $onCreate; 31 | 32 | return $this; 33 | } 34 | 35 | public function addClass(string $class): self 36 | { 37 | $this->classes[] = $class; 38 | 39 | return $this; 40 | } 41 | 42 | public function create(Multiplier $multiplier): SubmitButton 43 | { 44 | $button = new SubmitButton($this->caption); 45 | 46 | $button->setHtmlAttribute('class', implode(' ', $this->classes)); 47 | $button->setValidationScope([]) 48 | ->setOmitted(); 49 | 50 | $button->onClick[] = $button->onInvalidClick[] = [$multiplier, 'resetFormEvents']; 51 | 52 | foreach ($this->onCreate as $callback) { 53 | $callback($button); 54 | } 55 | 56 | return $button; 57 | } 58 | 59 | } 60 | -------------------------------------------------------------------------------- /src/ComponentResolver.php: -------------------------------------------------------------------------------- 1 | httpData = $httpData; 38 | $this->maxCopies = $maxCopies; 39 | $this->defaults = $defaults; 40 | $this->minCopies = $minCopies; 41 | 42 | foreach ($httpData as $index => $_) { 43 | if (str_starts_with((string) $index, Multiplier::SUBMIT_CREATE_NAME)) { 44 | $this->createAction = true; 45 | $num = substr($index, 18); 46 | if ($num) { 47 | $this->createNum = (int) $num; 48 | } 49 | 50 | return; 51 | } 52 | } 53 | 54 | foreach ($httpData as $index => $row) { 55 | if (is_array($row) && array_key_exists(Multiplier::SUBMIT_REMOVE_NAME, $row)) { 56 | $this->removeAction = true; 57 | $this->removeId = $index; 58 | 59 | break; 60 | } 61 | } 62 | } 63 | 64 | public function getCreateNum(): int 65 | { 66 | return $this->createNum; 67 | } 68 | 69 | /** 70 | * @return mixed[] 71 | */ 72 | public function getDefaults(): array 73 | { 74 | return array_slice($this->defaults, 0, $this->maxCopies, true); 75 | } 76 | 77 | /** 78 | * @return mixed[] 79 | */ 80 | public function getValues(): array 81 | { 82 | return array_slice($this->getPurgedHttpData(), 0, $this->maxCopies, true); 83 | } 84 | 85 | /** 86 | * @return mixed[] 87 | */ 88 | public function getPurgedHttpData(): array 89 | { 90 | if ($this->purgedHttpData === null) { 91 | $httpData = $this->httpData; 92 | 93 | foreach ($httpData as $index => &$row) { 94 | if (!is_array($row)) { 95 | unset($httpData[$index]); 96 | } elseif (array_key_exists(Multiplier::SUBMIT_REMOVE_NAME, $row)) { 97 | unset($row[Multiplier::SUBMIT_REMOVE_NAME]); 98 | } 99 | } 100 | 101 | if ($this->isRemoveAction()) { 102 | if (count($httpData) > $this->minCopies) { 103 | unset($httpData[$this->getRemoveId()]); 104 | } else { 105 | $this->reached = true; 106 | } 107 | } 108 | 109 | $this->purgedHttpData = $httpData; 110 | } 111 | 112 | return $this->purgedHttpData; 113 | } 114 | 115 | public function isCreateAction(): bool 116 | { 117 | return $this->createAction; 118 | } 119 | 120 | public function isRemoveAction(): bool 121 | { 122 | return $this->removeAction; 123 | } 124 | 125 | public function getRemoveId(): int|string|null 126 | { 127 | return $this->removeId; 128 | } 129 | 130 | public function reachedMinLimit(): bool 131 | { 132 | return $this->reached; 133 | } 134 | 135 | } 136 | -------------------------------------------------------------------------------- /src/DI/MultiplierExtension.php: -------------------------------------------------------------------------------- 1 | Expect::string()->default('addMultiplier'), 24 | ]); 25 | } 26 | 27 | public function beforeCompile(): void 28 | { 29 | $builder = $this->getContainerBuilder(); 30 | $factory = $builder->getDefinitionByType(LatteFactory::class); 31 | 32 | if (!$factory instanceof FactoryDefinition) { 33 | throw new InvalidConfigurationException( 34 | sprintf( 35 | 'latte.latteFactory service definition must be of type %s, not %s', 36 | FactoryDefinition::class, 37 | $factory::class 38 | ) 39 | ); 40 | } 41 | 42 | $resultDefinition = $factory->getResultDefinition(); 43 | $resultDefinition->addSetup('addExtension', [new Statement(LatteMultiplierExtension::class)]); 44 | } 45 | 46 | public function afterCompile(ClassType $class): void 47 | { 48 | /** @var stdClass $config */ 49 | $config = $this->getConfig(); 50 | $init = $class->getMethods()['initialize']; 51 | $init->addBody(Multiplier::class . '::register(?);', [$config->name]); 52 | } 53 | 54 | } 55 | -------------------------------------------------------------------------------- /src/ISubmitter.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | public function getTags(): array 17 | { 18 | return [ 19 | 'multiplier' => [MultiplierNode::class, 'create'], 20 | 'n:multiplier' => [MultiplierNode::class, 'create'], 21 | 'multiplier:remove' => [MultiplierRemoveNode::class, 'create'], 22 | 'multiplier:add' => [MultiplierAddNode::class, 'create'], 23 | ]; 24 | } 25 | 26 | } 27 | -------------------------------------------------------------------------------- /src/Latte/Extension/Node/MultiplierAddNode.php: -------------------------------------------------------------------------------- 1 | expectArguments('multiplier name'); 26 | 27 | $node = new self(); 28 | $node->name = $tag->parser->parseUnquotedStringOrExpression(false); 29 | 30 | if ($tag->parser->stream->tryConsume(':') && !$tag->parser->stream->is(',')) { 31 | $node->part = $tag->parser->isEnd() 32 | ? new StringNode('1') 33 | : $tag->parser->parseUnquotedStringOrExpression(); 34 | } else { 35 | $node->part = new StringNode('1'); 36 | } 37 | 38 | $tag->parser->stream->tryConsume(','); 39 | 40 | $node->attributes = $tag->parser->parseArguments(); 41 | 42 | return $node; 43 | } 44 | 45 | public static function getCreateButton(Multiplier $multiplier, int|string $buttonId): ?Submitter 46 | { 47 | return $multiplier->getCreateButtons()[$buttonId] ?? null; 48 | } 49 | 50 | public function print(PrintContext $context): string 51 | { 52 | return $context->format( 53 | ($this->name instanceof StringNode 54 | ? '$ʟ_multiplier = end($this->global->formsStack)[%node];' 55 | : '$ʟ_multiplier = is_object($ʟ_tmp = %node) ? $ʟ_tmp : end($this->global->formsStack)[$ʟ_tmp];') 56 | . 'if ($ʟ_input = %raw::getCreateButton($ʟ_multiplier, %node)) {' 57 | . 'echo $ʟ_input->getControl()' 58 | . ($this->attributes->items ? '->addAttributes(%node)' : '') 59 | . ';' 60 | . '} %4.line', 61 | $this->name, 62 | self::class, 63 | $this->part, 64 | $this->attributes, 65 | $this->position 66 | ); 67 | } 68 | 69 | public function &getIterator(): \Generator 70 | { 71 | yield $this->name; 72 | yield $this->attributes; 73 | yield $this->part; 74 | } 75 | 76 | } 77 | -------------------------------------------------------------------------------- /src/Latte/Extension/Node/MultiplierNode.php: -------------------------------------------------------------------------------- 1 | 22 | */ 23 | public static function create(Tag $tag): Generator 24 | { 25 | $tag->outputMode = $tag::OutputRemoveIndentation; 26 | $tag->expectArguments(); 27 | 28 | $node = new static(); 29 | $node->name = $tag->parser->parseUnquotedStringOrExpression(); 30 | 31 | [$node->content] = yield; 32 | 33 | return $node; 34 | } 35 | 36 | public function print(PrintContext $context): string 37 | { 38 | return $context->format( 39 | '$multiplier = ' 40 | . ($this->name instanceof StringNode 41 | ? 'end($this->global->formsStack)[%node];' 42 | : 'is_object($ʟ_tmp = %node) ? $ʟ_tmp : end($this->global->formsStack)[$ʟ_tmp];') 43 | . 'foreach ($multiplier->getContainers() as $formContainer) {' 44 | . "\n" 45 | . '$this->global->formsStack[] = $formContainer;' 46 | . ' %line %node ' // content 47 | . 'array_pop($this->global->formsStack);' 48 | . "\n" 49 | . '}' 50 | . '$formContainer = end($this->global->formsStack);' 51 | . "\n\n", 52 | $this->name, 53 | $this->position, 54 | $this->content 55 | ); 56 | } 57 | 58 | public function &getIterator(): \Generator 59 | { 60 | yield $this->name; 61 | yield $this->content; 62 | } 63 | 64 | } 65 | -------------------------------------------------------------------------------- /src/Latte/Extension/Node/MultiplierRemoveNode.php: -------------------------------------------------------------------------------- 1 | attributes = $tag->parser->parseArguments(); 23 | 24 | return $node; 25 | } 26 | 27 | /** 28 | * @param Container[] $formsStack 29 | */ 30 | public static function getRemoveButton(array $formsStack): ?IComponent 31 | { 32 | $container = end($formsStack); 33 | 34 | if (!$container || !$container->getParent() instanceof Multiplier) { 35 | throw new LogicException('{multiplier:remove} macro must be inside {multiplier} macro.'); 36 | } 37 | 38 | return $container->getComponent(Multiplier::SUBMIT_REMOVE_NAME, false); 39 | } 40 | 41 | public function print(PrintContext $context): string 42 | { 43 | return $context->format( 44 | 'if ($ʟ_input = %raw::getRemoveButton($this->global->formsStack)) {' 45 | . 'echo $ʟ_input->getControl()' 46 | . ($this->attributes->items ? '->addAttributes(%1.node)' : '') 47 | . ';' 48 | . '} %2.line', 49 | self::class, 50 | $this->attributes, 51 | $this->position 52 | ); 53 | } 54 | 55 | public function &getIterator(): \Generator 56 | { 57 | yield $this->attributes; 58 | } 59 | 60 | } 61 | -------------------------------------------------------------------------------- /src/Multiplier.php: -------------------------------------------------------------------------------- 1 | factory = $factory; 76 | $this->minCopies = $this->copyNumber = $copyNumber; 77 | $this->maxCopies = $maxCopies; 78 | 79 | $this->monitor(Form::class, function (Form $form): void { 80 | $this->form = $form; 81 | 82 | if ($this->getCurrentGroup() === null) { 83 | $this->setCurrentGroup($form->getCurrentGroup()); 84 | } 85 | 86 | if ($form instanceof \Nette\Application\UI\Form) { 87 | if ($form->isAnchored()) { 88 | $this->whenAttached(); 89 | } else { 90 | $form->onAnchor[] = function (): void { 91 | $this->whenAttached(); 92 | }; 93 | } 94 | } 95 | 96 | $form->onRender[] = function (): void { 97 | $this->whenAttached(); 98 | }; 99 | }); 100 | $this->monitor(self::class, [$this, 'whenAttached']); 101 | } 102 | 103 | public static function register(string $name = 'addMultiplier'): void 104 | { 105 | Container::extensionMethod($name, function (Container $form, $name, $factory, $copyNumber = 1, $maxCopies = null) { 106 | $multiplier = new Multiplier($factory, $copyNumber, $maxCopies); 107 | $multiplier->setCurrentGroup($form->getCurrentGroup()); 108 | 109 | return $form[$name] = $multiplier; 110 | }); 111 | } 112 | 113 | /** 114 | * @return ($throw is true ? Form : ?Form) 115 | */ 116 | public function getForm(bool $throw = true): ?Form 117 | { 118 | if ($this->form) { 119 | return $this->form; 120 | } 121 | 122 | return parent::getForm($throw); 123 | } 124 | 125 | public function setResetKeys(bool $reset = true): self 126 | { 127 | $this->resetKeys = $reset; 128 | 129 | return $this; 130 | } 131 | 132 | public function setMinCopies(int $minCopies): self 133 | { 134 | $this->minCopies = $minCopies; 135 | 136 | return $this; 137 | } 138 | 139 | public function setFactory(callable $factory): self 140 | { 141 | $this->factory = $factory; 142 | 143 | return $this; 144 | } 145 | 146 | public function getMaxCopies(): ?int 147 | { 148 | return $this->maxCopies; 149 | } 150 | 151 | public function getMinCopies(): ?int 152 | { 153 | return $this->minCopies; 154 | } 155 | 156 | public function getCopyNumber(): int 157 | { 158 | return $this->copyNumber; 159 | } 160 | 161 | public function addRemoveButton(Html|string|null $caption = null): RemoveButton 162 | { 163 | return $this->removeButton = new RemoveButton($caption); 164 | } 165 | 166 | public function addCreateButton(?string $caption = null, int $copyCount = 1): CreateButton 167 | { 168 | return $this->createButtons[$copyCount] = new CreateButton($caption, $copyCount); 169 | } 170 | 171 | /** 172 | * @param Control[]|null $controls 173 | */ 174 | public function validate(?array $controls = null): void 175 | { 176 | $components = $controls ?? array_filter($this->getComponents(), fn ($component) => $component instanceof Control || $component instanceof Container); 177 | 178 | foreach ($components as $index => $control) { 179 | foreach ($this->noValidate as $item) { 180 | if ($control === $item) { 181 | unset($components[$index]); 182 | } 183 | } 184 | } 185 | 186 | parent::validate($components); 187 | } 188 | 189 | /** 190 | * @param mixed[]|object $defaults 191 | */ 192 | public function addCopy(?int $number = null, array|object $defaults = []): Container 193 | { 194 | if (!is_numeric($number)) { 195 | $number = $this->createNumber(); 196 | } else { 197 | /** @var Container|null $component */ 198 | $component = $this->getComponent((string) $number, false); 199 | if ($component !== null) { 200 | return $component; 201 | } 202 | } 203 | 204 | $container = $this->createContainer(); 205 | if ($defaults) { 206 | $container->setDefaults($defaults, $this->erase); 207 | } 208 | 209 | $this->attachContainer($container, (string) $number); 210 | $this->attachRemoveButton($container); 211 | 212 | $this->totalCopies++; 213 | 214 | return $container; 215 | } 216 | 217 | public function createCopies(): void 218 | { 219 | if ($this->created === true) { 220 | return; 221 | } 222 | 223 | $this->created = true; 224 | 225 | $resolver = new ComponentResolver($this->httpData, $this->values, $this->maxCopies, $this->minCopies); 226 | 227 | $this->attachCreateButtons(); 228 | $this->createComponents($resolver); 229 | $this->detachCreateButtons(); 230 | 231 | if ($this->maxCopies === null || $this->totalCopies < $this->maxCopies) { 232 | $this->attachCreateButtons(); 233 | } 234 | 235 | if ($this->form !== null && $this->removeButton !== null && $resolver->isRemoveAction() && $this->totalCopies >= $this->minCopies && !$resolver->reachedMinLimit()) { 236 | 237 | // Create dummy remove button. Without this, Nette will validate, 238 | // even though the original button has empty validation scope, 239 | // since the button has actually been removed. 240 | $this->form->setSubmittedBy($this->removeButton->create($this)); 241 | 242 | $this->resetFormEvents(); 243 | 244 | $this->onRemoveEvent(); 245 | } 246 | 247 | // onCreateEvent 248 | $this->onCreateEvent(); 249 | } 250 | 251 | /** 252 | * @return Submitter[] 253 | */ 254 | public function getCreateButtons(): array 255 | { 256 | if ($this->maxCopies !== null && $this->totalCopies >= $this->maxCopies) { 257 | return []; 258 | } 259 | 260 | $buttons = []; 261 | foreach ($this->createButtons as $button) { 262 | $buttons[$button->getCopyCount()] = $this->getComponent($button->getComponentName()); 263 | } 264 | 265 | return $buttons; 266 | } 267 | 268 | /** 269 | * @internal 270 | */ 271 | public function resetFormEvents(): void 272 | { 273 | if ($this->form === null) { 274 | return; 275 | } 276 | 277 | $this->form->onSuccess = $this->form->onError = $this->form->onSubmit = []; 278 | } 279 | 280 | /** 281 | * @param string|object|bool|null $returnType 'array' for array 282 | * @param Control[]|null $controls 283 | * @return object|mixed[] 284 | */ 285 | public function getValues(string|object|bool|null $returnType = null, ?array $controls = null): object|array 286 | { 287 | if (!$this->resetKeys) { 288 | return parent::getValues($returnType, $controls); 289 | } 290 | 291 | /** @var mixed[] $values */ 292 | $values = parent::getValues(self::Array, $controls); 293 | $values = array_values($values); 294 | 295 | if ($returnType === true) { 296 | trigger_error(static::class . '::' . __FUNCTION__ . "(true) is deprecated, use getValues('array').", E_USER_DEPRECATED); 297 | $returnType = self::Array; 298 | } 299 | 300 | return $returnType === self::Array ? $values : ArrayHash::from($values); 301 | } 302 | 303 | /** 304 | * @return Iterator|Control[] 305 | */ 306 | public function getControls(): Iterator 307 | { 308 | $this->createCopies(); 309 | 310 | return parent::getControls(); 311 | } 312 | 313 | /** 314 | * @return array 315 | */ 316 | public function getContainers(): iterable 317 | { 318 | $this->createCopies(); 319 | 320 | $containers = array_filter($this->getComponents(), fn ($component) => $component instanceof Container); 321 | 322 | return $containers; 323 | } 324 | 325 | /** 326 | * @param mixed[]|object $values 327 | * @internal 328 | */ 329 | public function setValues(array|object $values, bool $erase = false, bool $onlyDisabled = false): static 330 | { 331 | $values = $values instanceof Traversable ? iterator_to_array($values) : (array) $values; 332 | 333 | $this->values = $values; 334 | $this->erase = $erase; 335 | 336 | if ($this->created) { 337 | foreach ($this->getContainers() as $container) { 338 | $this->removeComponent($container); 339 | } 340 | 341 | $this->created = false; 342 | $this->detachCreateButtons(); 343 | $this->createCopies(); 344 | } 345 | 346 | return $this; 347 | } 348 | 349 | protected function whenAttached(): void 350 | { 351 | if ($this->attachedCalled) { 352 | return; 353 | } 354 | 355 | $this->loadHttpData(); 356 | $this->createCopies(); 357 | 358 | $this->attachedCalled = true; 359 | } 360 | 361 | protected function onCreateEvent(): void 362 | { 363 | foreach ($this->onCreate as $callback) { 364 | foreach ($this->getContainers() as $container) { 365 | $callback($container); 366 | } 367 | } 368 | } 369 | 370 | protected function onRemoveEvent(): void 371 | { 372 | foreach ($this->onRemove as $callback) { 373 | $callback($this); 374 | } 375 | } 376 | 377 | protected function isValidMaxCopies(): bool 378 | { 379 | return $this->maxCopies === null || $this->totalCopies < $this->maxCopies; 380 | } 381 | 382 | protected function isFormSubmitted(): bool 383 | { 384 | return $this->getForm(false) !== null && $this->getForm()->isAnchored() && $this->getForm()->isSubmitted(); 385 | } 386 | 387 | protected function loadHttpData(): void 388 | { 389 | if ($this->form !== null && $this->isFormSubmitted()) { 390 | /** @var array $httpData The other types from the union can only be returned when the htmlName argument is passed. https://github.com/nette/forms/pull/333 */ 391 | $httpData = $this->form->getHttpData(); 392 | $this->httpData = (array) Arrays::get($httpData, $this->getHtmlName(), []); 393 | } 394 | } 395 | 396 | protected function createNumber(): int 397 | { 398 | $count = count(array_filter($this->getComponents(), fn ($component) => $component instanceof Form)); 399 | while ($this->getComponent((string) $count, false)) { 400 | $count++; 401 | } 402 | 403 | return $count; 404 | } 405 | 406 | protected function fillContainer(Container $container): void 407 | { 408 | call_user_func($this->factory, $container, $this->getForm()); 409 | } 410 | 411 | /** 412 | * @return string[] 413 | * @throws InvalidStateException when not attached. 414 | */ 415 | protected function getHtmlName(): array 416 | { 417 | return explode('-', $this->lookupPath(Form::class)); 418 | } 419 | 420 | protected function createContainer(): Container 421 | { 422 | $control = new Container(); 423 | $control->setCurrentGroup($this->currentGroup); 424 | $this->fillContainer($control); 425 | 426 | return $control; 427 | } 428 | 429 | /** 430 | * Return name of first submit button 431 | */ 432 | protected function getFirstSubmit(): ?string 433 | { 434 | $submits = array_filter($this->getComponents(), fn ($component) => $component instanceof SubmitButton); 435 | if ($submits) { 436 | return reset($submits)->getName(); 437 | } 438 | 439 | return null; 440 | } 441 | 442 | protected function attachContainer(Container $container, ?string $name): void 443 | { 444 | $this->addComponent($container, $name, $this->getFirstSubmit()); 445 | } 446 | 447 | protected function removeComponentProperly(IComponent $component): void 448 | { 449 | if ($this->getCurrentGroup() !== null && $component instanceof Control) { 450 | $this->getCurrentGroup()->remove($component); 451 | } 452 | 453 | $this->removeComponent($component); 454 | } 455 | 456 | private function createComponents(ComponentResolver $resolver): void 457 | { 458 | $containers = []; 459 | 460 | // Components from httpData 461 | if ($this->isFormSubmitted()) { 462 | foreach ($resolver->getValues() as $number => $_) { 463 | $containers[] = $container = $this->addCopy($number); 464 | 465 | /** @var BaseControl $control */ 466 | foreach ($container->getControls() as $control) { 467 | $control->loadHttpData(); 468 | } 469 | } 470 | } else { // Components from default values 471 | foreach ($resolver->getDefaults() as $number => $values) { 472 | $containers[] = $this->addCopy($number, $values); 473 | } 474 | } 475 | 476 | // Default number of copies 477 | if (!$this->isFormSubmitted() && !$this->values) { 478 | $copyNumber = $this->copyNumber; 479 | while ($copyNumber > 0 && $this->isValidMaxCopies()) { 480 | $containers[] = $container = $this->addCopy(); 481 | $copyNumber--; 482 | } 483 | } 484 | 485 | // Dynamic 486 | foreach ($this->onCreateComponents as $callback) { 487 | $callback($this); 488 | } 489 | 490 | // New containers, if create button hitted 491 | if ($this->form !== null && $resolver->isCreateAction() && $this->form->isValid()) { 492 | $count = $resolver->getCreateNum(); 493 | while ($count > 0 && $this->isValidMaxCopies()) { 494 | $this->noValidate[] = $containers[] = $container = $this->addCopy(); 495 | $container->setValues($this->createContainer()->getValues(self::Array)); 496 | $count--; 497 | } 498 | } 499 | 500 | if ($this->removeButton && $this->totalCopies <= $this->minCopies) { 501 | foreach ($containers as $container) { 502 | $this->detachRemoveButton($container); 503 | } 504 | } 505 | } 506 | 507 | private function detachCreateButtons(): void 508 | { 509 | foreach ($this->createButtons as $button) { 510 | $this->removeComponentProperly($this->getComponent($button->getComponentName())); 511 | } 512 | } 513 | 514 | private function attachCreateButtons(): void 515 | { 516 | foreach ($this->createButtons as $button) { 517 | $this->addComponent($button->create($this), $button->getComponentName()); 518 | } 519 | } 520 | 521 | private function detachRemoveButton(Container $container): void 522 | { 523 | $button = $container->getComponent(self::SUBMIT_REMOVE_NAME); 524 | if ($this->getCurrentGroup() !== null) { 525 | $this->getCurrentGroup()->remove($button); 526 | } 527 | 528 | $container->removeComponent($button); 529 | } 530 | 531 | private function attachRemoveButton(Container $container): void 532 | { 533 | if (!$this->removeButton) { 534 | return; 535 | } 536 | 537 | $container->addComponent($this->removeButton->create($this), self::SUBMIT_REMOVE_NAME); 538 | } 539 | 540 | } 541 | -------------------------------------------------------------------------------- /src/Submitter.php: -------------------------------------------------------------------------------- 1 | copyCount = $copyCount; 17 | } 18 | 19 | public function getCopyCount(): int 20 | { 21 | return $this->copyCount; 22 | } 23 | 24 | } 25 | --------------------------------------------------------------------------------