├── Classes ├── Configuration │ └── Extension.php ├── Event │ └── CopyVariantEvent.php ├── EventListener │ └── AdaptVariantConditionEventListener.php ├── Finisher │ └── SaveToDatabaseFinisher.php ├── FormElements │ ├── RepeatableContainer.php │ └── RepeatableContainerInterface.php ├── Hooks │ └── FormHooks.php └── Service │ └── CopyService.php ├── Configuration ├── .htaccess ├── Icons.php ├── JavaScriptModules.php ├── Services.yaml ├── Sets │ └── RepeatableFormElements │ │ ├── config.yaml │ │ └── setup.typoscript ├── TCA │ └── Overrides │ │ └── sys_template.php ├── TypoScript │ └── setup.typoscript └── Yaml │ ├── FormSetup.yaml │ └── FormSetupBackend.yaml ├── LICENSE.txt ├── README.md ├── Resources ├── Private │ ├── ExampleFormDefinitions │ │ └── extended-save-to-database-finisher.form.yaml │ ├── Frontend │ │ └── Partials │ │ │ └── RepeatableContainer.html │ └── Language │ │ ├── Database.xlf │ │ ├── de.Database.xlf │ │ ├── de.locallang.xlf │ │ └── locallang.xlf └── Public │ ├── Icons │ ├── Extension.svg │ └── t3-form-icon-repeatable-container.svg │ ├── JavaScript │ ├── backend │ │ └── form-editor │ │ │ └── view-model.js │ └── frontend │ │ └── repeatable-container.js │ └── StyleSheets │ └── app.css ├── composer.json ├── ext_emconf.php └── ext_localconf.php /Classes/Configuration/Extension.php: -------------------------------------------------------------------------------- 1 | 9 | * Copyright (C) 2021 Elias Häußler 10 | * 11 | * This program is free software: you can redistribute it and/or modify 12 | * it under the terms of the GNU General Public License as published by 13 | * the Free Software Foundation, either version 2 of the License, or 14 | * (at your option) any later version. 15 | * 16 | * This program is distributed in the hope that it will be useful, 17 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 18 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 19 | * GNU General Public License for more details. 20 | * 21 | * You should have received a copy of the GNU General Public License 22 | * along with this program. If not, see . 23 | */ 24 | 25 | namespace TRITUM\RepeatableFormElements\Configuration; 26 | 27 | use TRITUM\RepeatableFormElements\Hooks\FormHooks; 28 | use TYPO3\CMS\Core\Utility\ExtensionManagementUtility; 29 | 30 | /** 31 | * Extension 32 | * 33 | * @author Ralf Zimmermann | dreistrom.land AG 34 | * @author Elias Häußler 35 | * @author Christian Seyfferth | dreistrom.land AG 36 | * @license GPL-2.0-or-later 37 | */ 38 | final class Extension 39 | { 40 | public const KEY = 'repeatable_form_elements'; 41 | 42 | public static function addTypoScriptSetup(): void 43 | { 44 | // @todo: maybe move this to 'EXT:repeatable_form_elements/ext_typoscript_setup.typoscript' 45 | ExtensionManagementUtility::addTypoScriptSetup(trim(' 46 | module.tx_form { 47 | settings { 48 | yamlConfigurations { 49 | 1511193633 = EXT:repeatable_form_elements/Configuration/Yaml/FormSetup.yaml 50 | 1511193634 = EXT:repeatable_form_elements/Configuration/Yaml/FormSetupBackend.yaml 51 | } 52 | } 53 | } 54 | ')); 55 | } 56 | 57 | public static function registerHooks(): void 58 | { 59 | $GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['ext/form']['afterInitializeCurrentPage'][1511196413] = FormHooks::class; 60 | $GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['ext/form']['beforeRendering'][1511196413] = FormHooks::class; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /Classes/Event/CopyVariantEvent.php: -------------------------------------------------------------------------------- 1 | options = $options; 34 | $this->originalFormElement = $originalFormElement; 35 | $this->newFormElement = $newFormElement; 36 | $this->newIdentifier = $newIdentifier; 37 | } 38 | 39 | public function getOptions(): array 40 | { 41 | return $this->options; 42 | } 43 | 44 | public function setOptions(array $options): void 45 | { 46 | $this->options = $options; 47 | } 48 | 49 | public function getOriginalFormElement(): FormElementInterface 50 | { 51 | return $this->originalFormElement; 52 | } 53 | 54 | public function getNewFormElement(): FormElementInterface 55 | { 56 | return $this->newFormElement; 57 | } 58 | 59 | public function getNewIdentifier(): string 60 | { 61 | return $this->newIdentifier; 62 | } 63 | 64 | public function isVariantEnabled(): bool 65 | { 66 | return $this->variantEnabled; 67 | } 68 | 69 | public function setVariantEnabled(bool $variantEnabled): void 70 | { 71 | $this->variantEnabled = $variantEnabled; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /Classes/EventListener/AdaptVariantConditionEventListener.php: -------------------------------------------------------------------------------- 1 | getOptions(); 26 | $originalIdentifier = $event->getOriginalFormElement()->getIdentifier(); 27 | 28 | // get path strings for identifiers for replacement in condition 29 | // e.g. for `traverse(formValues, 'repeatablecontainer-1.0.checkbox-1')` 30 | $originalIdentifierAsPath = str_replace('.', '/', $originalIdentifier); 31 | $newIdentifierAsPath = str_replace('.', '/', $event->getNewIdentifier()); 32 | 33 | // adapt original condition to match identifier of the copied form element 34 | $options['condition'] = str_replace( 35 | [ 36 | $originalIdentifier, 37 | $originalIdentifierAsPath, 38 | ], 39 | [ 40 | $event->getNewIdentifier(), 41 | $newIdentifierAsPath, 42 | ], 43 | $options['condition'], 44 | ); 45 | 46 | $event->setOptions($options); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Classes/Finisher/SaveToDatabaseFinisher.php: -------------------------------------------------------------------------------- 1 | throwExceptionOnInconsistentConfiguration(); 30 | 31 | $table = $this->parseOption('table'); 32 | $table = is_string($table) ? $table : ''; 33 | $elementsConfiguration = $this->parseOption('elements'); 34 | $elementsConfiguration = is_array($elementsConfiguration) ? $elementsConfiguration : []; 35 | $databaseColumnMappingsConfiguration = $this->parseOption('databaseColumnMappings'); 36 | 37 | $this->databaseConnection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable($table); 38 | 39 | $databaseData = []; 40 | foreach ($databaseColumnMappingsConfiguration as $databaseColumnName => $databaseColumnConfiguration) { 41 | $value = $this->parseOption('databaseColumnMappings.' . $databaseColumnName . '.value'); 42 | if ( 43 | empty($value) 44 | && ($databaseColumnConfiguration['skipIfValueIsEmpty'] ?? false) === true 45 | ) { 46 | continue; 47 | } 48 | 49 | $databaseData[$databaseColumnName] = $value; 50 | } 51 | 52 | // decide which strategy to use 53 | $containerConfiguration = $this->parseOption('container'); 54 | if (!empty($containerConfiguration) && is_string($containerConfiguration)) { 55 | $this->processContainer($containerConfiguration, $elementsConfiguration, $databaseData, $table, $iterationCount); 56 | } else { 57 | $databaseData = $this->prepareData($elementsConfiguration, $databaseData); 58 | 59 | $this->saveToDatabase($databaseData, $table, $iterationCount); 60 | } 61 | 62 | } 63 | 64 | /** 65 | * This action will do mostly the same processing as the default processing but we need to set prefix for the finisher to find the correct element 66 | * @param string $containerPath the identifier of the container to process, can be for example `RootContainer` or `RootContainer.0.NestedContainer` 67 | * @param array $elementsConfiguration finisher-element-configuration 68 | * @param array $databaseData prepared data 69 | * @param string $table Tablename to save data to 70 | * @param int $iterationCount finisher iteration 71 | */ 72 | protected function processContainer( 73 | string $containerPath, 74 | array $elementsConfiguration, 75 | array $databaseData, 76 | string $table, 77 | int $iterationCount, 78 | ): void { 79 | $containerValues = ArrayUtility::getValueByPath($this->getFormValues(), $containerPath, '.'); 80 | foreach ($containerValues as $copyId => $containerItem) { 81 | $prefix = $containerPath . '.' . $copyId . '.'; 82 | // store data inside new array to keep prepared $databaseData for all iterations 83 | $itemDatabaseData = $this->prepareData($elementsConfiguration, $databaseData, $containerItem, $prefix); 84 | 85 | $this->saveToDatabase($itemDatabaseData, $table, $iterationCount, $copyId); 86 | } 87 | } 88 | 89 | /** 90 | * Adapted method for container data. 91 | * @param array $elementsConfiguration finisher element configuration 92 | * @param array $databaseData prepared data 93 | * @param array $values optional filled Array with form values to use 94 | * @param string $prefix prefix to get the form element object by a full identifier 95 | * @return array the filled database data 96 | */ 97 | protected function prepareData( 98 | array $elementsConfiguration, 99 | array $databaseData, 100 | array $values = [], 101 | string $prefix = '', 102 | ): array { 103 | if (empty($values)) { 104 | $values = $this->getFormValues(); 105 | } 106 | 107 | foreach ($values as $elementIdentifier => $elementValue) { 108 | if (!$this->canValueBeHandled($elementValue, $elementsConfiguration, $elementIdentifier, $prefix)) { 109 | continue; 110 | } 111 | 112 | if ($elementValue instanceof FileReference) { 113 | if (isset($elementsConfiguration[$elementIdentifier]['saveFileIdentifierInsteadOfUid'])) { 114 | $saveFileIdentifierInsteadOfUid = (bool)$elementsConfiguration[$elementIdentifier]['saveFileIdentifierInsteadOfUid']; 115 | } else { 116 | $saveFileIdentifierInsteadOfUid = false; 117 | } 118 | 119 | if ($saveFileIdentifierInsteadOfUid) { 120 | $elementValue = $elementValue->getOriginalResource()->getCombinedIdentifier(); 121 | } else { 122 | $elementValue = $elementValue->getOriginalResource()->getProperty('uid_local'); 123 | } 124 | } elseif (is_array($elementValue)) { 125 | $elementValue = implode(',', $elementValue); 126 | } elseif ($elementValue instanceof DateTimeInterface) { 127 | $format = $elementsConfiguration[$elementIdentifier]['dateFormat'] ?? 'U'; 128 | $elementValue = $elementValue->format($format); 129 | } 130 | 131 | $databaseData[$elementsConfiguration[$elementIdentifier]['mapOnDatabaseColumn']] = $elementValue; 132 | } 133 | 134 | return $databaseData; 135 | } 136 | 137 | /** 138 | * Save or insert the values from 139 | * $databaseData into the table $table 140 | * and provide some finisher variables 141 | */ 142 | protected function saveToDatabase( 143 | array $databaseData, 144 | string $table, 145 | int $iterationCount, 146 | ?int $containerItemKey = null, 147 | ): void { 148 | if (!empty($databaseData)) { 149 | if ($this->parseOption('mode') === 'update') { 150 | $whereClause = $this->parseOption('whereClause'); 151 | foreach ($whereClause as $columnName => $columnValue) { 152 | $whereClause[$columnName] = $this->parseOption('whereClause.' . $columnName); 153 | } 154 | $this->databaseConnection->update( 155 | $table, 156 | $databaseData, 157 | $whereClause, 158 | ); 159 | } else { 160 | $this->databaseConnection->insert($table, $databaseData); 161 | $insertedUid = (int)$this->databaseConnection->lastInsertId($table); 162 | $this->finisherContext->getFinisherVariableProvider()->add( 163 | $this->shortFinisherIdentifier, 164 | 'insertedUids.' . $iterationCount . (is_int($containerItemKey) ? '.' . $containerItemKey : ''), 165 | $insertedUid, 166 | ); 167 | 168 | $currentCount = $this->finisherContext->getFinisherVariableProvider()->get( 169 | $this->shortFinisherIdentifier, 170 | 'countInserts.', 171 | $iterationCount, 172 | 0, 173 | ); 174 | $this->finisherContext->getFinisherVariableProvider()->addOrUpdate( 175 | $this->shortFinisherIdentifier, 176 | 'countInserts.' . $iterationCount, 177 | $currentCount++, 178 | ); 179 | } 180 | } 181 | } 182 | 183 | /** 184 | * This will check if a element shall or can be handled 185 | * @param mixed $elementValue 186 | * @param array $elementsConfiguration 187 | * @param string $elementIdentifier 188 | * @param string $prefix 189 | * @return array 190 | */ 191 | private function canValueBeHandled(mixed $elementValue, array $elementsConfiguration, string $elementIdentifier, string $prefix): bool 192 | { 193 | $elementConfig = $elementsConfiguration[$elementIdentifier]; 194 | if (!isset($elementConfig)) { 195 | return false; 196 | } 197 | 198 | if ( 199 | ($elementValue === null || $elementValue === '') 200 | && isset($elementConfig['skipIfValueIsEmpty']) 201 | && $elementConfig['skipIfValueIsEmpty'] === true 202 | ) { 203 | return false; 204 | } 205 | 206 | $element = $this->getElementByIdentifier($prefix . $elementIdentifier); 207 | if (!($element instanceof FormElementInterface) || !isset($elementConfig['mapOnDatabaseColumn'])) { 208 | return false; 209 | } 210 | 211 | return true; 212 | } 213 | } 214 | -------------------------------------------------------------------------------- /Classes/FormElements/RepeatableContainer.php: -------------------------------------------------------------------------------- 1 | getPages() as $page) { 43 | $this->setRootRepeatableContainerIdentifiers($page, $formRuntime); 44 | } 45 | 46 | // first request 47 | if (!$lastPage) { 48 | return $currentPage; 49 | } 50 | 51 | $copyService = GeneralUtility::makeInstance(CopyService::class, $formRuntime); 52 | if ($this->userWentBackToPreviousStep($formRuntime, $currentPage, $lastPage)) { 53 | $copyService->createCopiesFromFormState(); 54 | } else { 55 | $copyService->createCopiesFromCurrentRequest(); 56 | } 57 | 58 | return $currentPage; 59 | } 60 | 61 | /** 62 | * @param FormRuntime $formRuntime 63 | * @param RootRenderableInterface $renderable 64 | */ 65 | public function beforeRendering(FormRuntime $formRuntime, RootRenderableInterface $renderable): void 66 | { 67 | if ($renderable instanceof FormElementInterface) { 68 | $properties = $renderable->getProperties(); 69 | 70 | $fluidAdditionalAttributes = $properties['fluidAdditionalAttributes'] ?? []; 71 | $fluidAdditionalAttributes['data-element-type'] = $renderable->getType(); 72 | if ($renderable->getType() === 'DatePicker') { 73 | $fluidAdditionalAttributes['data-element-datepicker-enabled'] = (int)$renderable->getProperties()['enableDatePicker']; 74 | $fluidAdditionalAttributes['data-element-datepicker-date-format'] = $renderable->getProperties()['dateFormat']; 75 | } 76 | 77 | $renderable->setProperty('fluidAdditionalAttributes', $fluidAdditionalAttributes); 78 | } 79 | } 80 | 81 | /** 82 | * @param RenderableInterface $renderable 83 | * @param FormRuntime $formRuntime 84 | * @param array $repeatableContainerIdentifiers 85 | * 86 | * @throws DuplicateFormElementException 87 | */ 88 | protected function setRootRepeatableContainerIdentifiers( 89 | RenderableInterface $renderable, 90 | FormRuntime $formRuntime, 91 | array $repeatableContainerIdentifiers = [], 92 | ): void { 93 | $isRepeatableContainer = $renderable instanceof RepeatableContainerInterface; 94 | 95 | $hasOriginalIdentifier = isset($renderable->getRenderingOptions()['_originalIdentifier']); 96 | if ($isRepeatableContainer) { 97 | $repeatableContainerIdentifiers[] = $renderable->getIdentifier(); 98 | if (!$hasOriginalIdentifier) { 99 | $renderable->setRenderingOption('_isRootRepeatableContainer', true); 100 | $renderable->setRenderingOption('_isReferenceContainer', true); 101 | } 102 | } 103 | 104 | if (!empty($repeatableContainerIdentifiers) && !$hasOriginalIdentifier) { 105 | $newIdentifier = implode('.0.', $repeatableContainerIdentifiers) . '.0'; 106 | if (!$isRepeatableContainer) { 107 | $newIdentifier .= '.' . $renderable->getIdentifier(); 108 | } 109 | $originalIdentifier = $renderable->getIdentifier(); 110 | $renderable->setRenderingOption('_originalIdentifier', $originalIdentifier); 111 | 112 | if ($renderable instanceof AbstractFormElement && $renderable->getDefaultValue()) { 113 | $formRuntime->getFormDefinition()->addElementDefaultValue($newIdentifier, $renderable->getDefaultValue()); 114 | } 115 | 116 | $formRuntime->getFormDefinition()->unregisterRenderable($renderable); 117 | $renderable->setIdentifier($newIdentifier); 118 | $formRuntime->getFormDefinition()->registerRenderable($renderable); 119 | 120 | $copyService = GeneralUtility::makeInstance(CopyService::class, $formRuntime); 121 | [$originalProcessingRule] = $copyService->copyProcessingRule($originalIdentifier, $newIdentifier); 122 | 123 | /** @var ValidatorInterface $validator */ 124 | foreach ($originalProcessingRule->getValidators() as $validator) { 125 | $renderable->addValidator($validator); 126 | } 127 | 128 | foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['ext/form']['afterBuildingFinished'] ?? [] as $className) { 129 | $hookObj = GeneralUtility::makeInstance($className); 130 | if (method_exists($hookObj, 'afterBuildingFinished')) { 131 | $hookObj->afterBuildingFinished($renderable); 132 | } 133 | } 134 | } 135 | 136 | if ($renderable instanceof CompositeRenderableInterface) { 137 | foreach ($renderable->getElements() as $childRenderable) { 138 | $this->setRootRepeatableContainerIdentifiers($childRenderable, $formRuntime, $repeatableContainerIdentifiers); 139 | } 140 | } 141 | } 142 | 143 | /** 144 | * returns TRUE if the user went back to any previous step in the form. 145 | * 146 | * @param FormRuntime $formRuntime 147 | * @param CompositeRenderableInterface|null $currentPage 148 | * @param CompositeRenderableInterface|null $lastPage 149 | * 150 | * @return bool 151 | */ 152 | protected function userWentBackToPreviousStep( 153 | FormRuntime $formRuntime, 154 | CompositeRenderableInterface $currentPage = null, 155 | CompositeRenderableInterface $lastPage = null, 156 | ): bool { 157 | return $currentPage !== null 158 | && $lastPage !== null 159 | && $currentPage->getIndex() < $lastPage->getIndex(); 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /Classes/Service/CopyService.php: -------------------------------------------------------------------------------- 1 | formRuntime = $formRuntime; 47 | $this->formState = $formRuntime->getFormState(); 48 | $this->formDefinition = $formRuntime->getFormDefinition(); 49 | $this->typeDefinitions = $this->formDefinition->getTypeDefinitions(); 50 | $this->features = GeneralUtility::makeInstance(Features::class); 51 | } 52 | 53 | /** 54 | * @return CopyService 55 | * @api 56 | */ 57 | public function createCopiesFromCurrentRequest(): CopyService 58 | { 59 | $requestArguments = $this->formRuntime->getRequest()->getArguments(); 60 | $this->removeDeletedRepeatableContainersFromFormValuesByRequest($requestArguments); 61 | $requestArguments = array_replace_recursive( 62 | $this->formState->getFormValues(), 63 | $requestArguments, 64 | ); 65 | 66 | $this->copyRepeatableContainersFromArguments($requestArguments); 67 | return $this; 68 | } 69 | 70 | /** 71 | * @return CopyService 72 | * @api 73 | */ 74 | public function createCopiesFromFormState(): CopyService 75 | { 76 | $this->copyRepeatableContainersFromArguments($this->formState->getFormValues()); 77 | return $this; 78 | } 79 | 80 | /** 81 | * @param string $originalFormElement 82 | * @param string $newElementCopy 83 | * @return ProcessingRule[] 84 | * @internal 85 | */ 86 | public function copyProcessingRule( 87 | string $originalFormElement, 88 | string $newElementCopy, 89 | ): array { 90 | $originalProcessingRule = $this->formRuntime->getFormDefinition()->getProcessingRule($originalFormElement); 91 | 92 | GeneralUtility::addInstance(PropertyMappingConfiguration::class, $originalProcessingRule->getPropertyMappingConfiguration()); 93 | $newProcessingRule = $this->formRuntime->getFormDefinition()->getProcessingRule($newElementCopy); 94 | 95 | try { 96 | $newProcessingRule->setDataType($originalProcessingRule->getDataType()); 97 | } catch (\TypeError $error) { 98 | } 99 | 100 | return [$originalProcessingRule, $newProcessingRule]; 101 | } 102 | 103 | /** 104 | * @param array $requestArguments 105 | * @param array $argumentPath 106 | */ 107 | protected function copyRepeatableContainersFromArguments( 108 | array $requestArguments, 109 | array $argumentPath = [], 110 | ): void { 111 | foreach ($requestArguments as $argumentKey => $argumentValue) { 112 | if (is_array($argumentValue)) { 113 | $originalContainer = $this->getRepeatableContainerByOriginalIdentifier((string)$argumentKey); 114 | $copyIndexes = array_keys($argumentValue); 115 | unset($copyIndexes[0]); 116 | $argumentPath[] = $argumentKey; 117 | 118 | if ( 119 | $originalContainer 120 | && count(array_filter(array_keys($copyIndexes), 'is_string')) === 0 121 | ) { 122 | $copyIndexes = ArrayUtility::sortArrayWithIntegerKeys($copyIndexes); 123 | 124 | if (count($argumentPath) <= 1) { 125 | $referenceContainer = $originalContainer; 126 | } else { 127 | $referenceContainerPath = $argumentPath; 128 | $referenceContainerPath[] = 0; 129 | $referenceContainerIdentifier = implode('.', $referenceContainerPath); 130 | $referenceContainer = $this->formDefinition->getElementByIdentifier($referenceContainerIdentifier); 131 | } 132 | 133 | $firstReferenceContainer = $referenceContainer; 134 | $firstReferenceContainer->setRenderingOption('_isReferenceContainer', true); 135 | $firstReferenceContainer->setRenderingOption('_copyMother', $originalContainer->getIdentifier()); 136 | 137 | $minimumCopies = (int)$firstReferenceContainer->getProperties()['minimumCopies']; 138 | $maximumCopies = (int)$firstReferenceContainer->getProperties()['maximumCopies']; 139 | 140 | $copyNumber = 1; 141 | foreach ($copyIndexes as $copyIndex) { 142 | $contextPath = $argumentPath; 143 | $contextPath[] = $copyIndex; 144 | $newIdentifier = implode('.', $contextPath); 145 | 146 | $referenceContainer = $this->copyRepeatableContainer($originalContainer, $referenceContainer, $newIdentifier); 147 | $referenceContainer->setRenderingOption('_copyReference', $firstReferenceContainer->getIdentifier()); 148 | 149 | if ($copyNumber > $maximumCopies) { 150 | $this->addError($referenceContainer, 1518701681, 'The maximum number of copies has been reached'); 151 | } 152 | $copyNumber++; 153 | } 154 | 155 | if ($copyNumber - 1 < $minimumCopies) { 156 | $this->addError($firstReferenceContainer, 1518701682, 'The minimum number of copies has not yet been reached'); 157 | } 158 | } 159 | 160 | $this->copyRepeatableContainersFromArguments($argumentValue, $argumentPath); 161 | array_pop($argumentPath); 162 | } 163 | } 164 | } 165 | 166 | /** 167 | * @param RepeatableContainerInterface $copyFromContainer 168 | * @param RepeatableContainerInterface $moveAfterContainer 169 | * @param string $newIdentifier 170 | * 171 | * @return RepeatableContainerInterface 172 | */ 173 | protected function copyRepeatableContainer( 174 | RepeatableContainerInterface $copyFromContainer, 175 | RepeatableContainerInterface $moveAfterContainer, 176 | string $newIdentifier, 177 | ): RepeatableContainerInterface { 178 | $typeName = $copyFromContainer->getType(); 179 | $implementationClassName = $this->typeDefinitions[$typeName]['implementationClassName']; 180 | $parentRenderableForNewContainer = $moveAfterContainer->getParentRenderable(); 181 | 182 | $newContainer = GeneralUtility::makeInstance($implementationClassName, $newIdentifier, $typeName); 183 | $this->copyOptions($newContainer, $copyFromContainer); 184 | 185 | $parentRenderableForNewContainer->addElement($newContainer); 186 | $parentRenderableForNewContainer->moveElementAfter($newContainer, $moveAfterContainer); 187 | 188 | foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['ext/form']['afterBuildingFinished'] ?? [] as $className) { 189 | $hookObj = GeneralUtility::makeInstance($className); 190 | if (method_exists($hookObj, 'afterBuildingFinished')) { 191 | $hookObj->afterBuildingFinished($newContainer); 192 | } 193 | } 194 | 195 | foreach ($copyFromContainer->getElements() as $originalFormElement) { 196 | $this->createNestedElements($originalFormElement, $newContainer, $copyFromContainer->getIdentifier(), $newIdentifier); 197 | } 198 | 199 | return $newContainer; 200 | } 201 | 202 | /** 203 | * @param FormElementInterface $newElementCopy 204 | * @param FormElementInterface $originalFormElement 205 | */ 206 | protected function copyOptions( 207 | FormElementInterface $newElementCopy, 208 | FormElementInterface $originalFormElement, 209 | ): void { 210 | $newElementCopy->setLabel($originalFormElement->getLabel()); 211 | $newElementCopy->setDefaultValue($originalFormElement->getDefaultValue()); 212 | foreach ($originalFormElement->getProperties() as $key => $value) { 213 | $newElementCopy->setProperty($key, $value); 214 | } 215 | foreach ($originalFormElement->getRenderingOptions() as $key => $value) { 216 | if ( 217 | $key === '_isRootRepeatableContainer' 218 | || $key === '_originalIdentifier' 219 | || $key === '_isReferenceContainer' 220 | ) { 221 | continue; 222 | } 223 | $newElementCopy->setRenderingOption($key, $value); 224 | } 225 | 226 | [$originalProcessingRule] = $this->copyProcessingRule($originalFormElement->getIdentifier(), $newElementCopy->getIdentifier()); 227 | 228 | /** @var ValidatorInterface $validator */ 229 | foreach ($originalProcessingRule->getValidators() as $validator) { 230 | $newElementCopy->addValidator($validator); 231 | } 232 | } 233 | 234 | /** 235 | * @param FormElementInterface $originalFormElement 236 | * @param CompositeRenderableInterface $parentFormElementCopy 237 | * @param string $identifierOriginal 238 | * @param string $identifierReplacement 239 | */ 240 | protected function createNestedElements( 241 | FormElementInterface $originalFormElement, 242 | CompositeRenderableInterface $parentFormElementCopy, 243 | string $identifierOriginal, 244 | string $identifierReplacement, 245 | ): void { 246 | $newIdentifier = str_replace($identifierOriginal, $identifierReplacement, $originalFormElement->getIdentifier()); 247 | $newFormElement = $parentFormElementCopy->createElement( 248 | $newIdentifier, 249 | $originalFormElement->getType(), 250 | ); 251 | $this->copyOptions($newFormElement, $originalFormElement); 252 | $this->copyProcessingRule($originalFormElement->getIdentifier(), $newIdentifier); 253 | $this->copyVariants($originalFormElement, $newFormElement, $newIdentifier); 254 | 255 | foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['ext/form']['afterBuildingFinished'] ?? [] as $className) { 256 | $hookObj = GeneralUtility::makeInstance($className); 257 | if (method_exists($hookObj, 'afterBuildingFinished')) { 258 | $hookObj->afterBuildingFinished($newFormElement); 259 | } 260 | } 261 | 262 | if ($originalFormElement instanceof CompositeRenderableInterface) { 263 | foreach ($originalFormElement->getElements() as $originalChildFormElement) { 264 | $this->createNestedElements($originalChildFormElement, $newFormElement, $identifierOriginal, $identifierReplacement); 265 | } 266 | } 267 | } 268 | 269 | /** 270 | * @param string $originalIdentifier 271 | * @return RepeatableContainerInterface|null 272 | */ 273 | protected function getRepeatableContainerByOriginalIdentifier(string $originalIdentifier): ?RepeatableContainerInterface 274 | { 275 | if ( 276 | !isset($this->repeatableContainersByOriginalIdentifier[$originalIdentifier]) 277 | || $this->repeatableContainersByOriginalIdentifier[$originalIdentifier] === null 278 | ) { 279 | foreach ($this->formDefinition->getRenderablesRecursively() as $formElement) { 280 | $renderingOptions = $formElement->getRenderingOptions(); 281 | if ( 282 | $formElement instanceof RepeatableContainerInterface 283 | && ($renderingOptions['_originalIdentifier'] ?? null) === $originalIdentifier 284 | && (bool)$renderingOptions['_isRootRepeatableContainer'] === true 285 | ) { 286 | $this->repeatableContainersByOriginalIdentifier[$originalIdentifier] = $formElement; 287 | } 288 | } 289 | if (!isset($this->repeatableContainersByOriginalIdentifier[$originalIdentifier])) { 290 | $this->repeatableContainersByOriginalIdentifier[$originalIdentifier] = null; 291 | } 292 | } 293 | 294 | return $this->repeatableContainersByOriginalIdentifier[$originalIdentifier]; 295 | } 296 | 297 | /** 298 | * @param FormElementInterface $formElement 299 | * @param int $timestamp 300 | * @param string $defaultMessage 301 | */ 302 | protected function addError( 303 | FormElementInterface $formElement, 304 | int $timestamp, 305 | string $defaultMessage = '', 306 | ): void { 307 | $error = GeneralUtility::makeInstance( 308 | Error::class, 309 | GeneralUtility::makeInstance(TranslationService::class)->translateFormElementError( 310 | $formElement, 311 | $timestamp, 312 | [], 313 | $defaultMessage, 314 | $this->formRuntime, 315 | ), 316 | $timestamp, 317 | ); 318 | $this->formDefinition 319 | ->getProcessingRule($formElement->getIdentifier()) 320 | ->getProcessingMessages() 321 | ->addError($error); 322 | } 323 | 324 | /** 325 | * @param array $requestArguments 326 | * @param array $argumentPath 327 | */ 328 | protected function removeDeletedRepeatableContainersFromFormValuesByRequest( 329 | array $requestArguments, 330 | array $argumentPath = [], 331 | ): void { 332 | foreach ($requestArguments as $argumentKey => $argumentValue) { 333 | if (is_array($argumentValue)) { 334 | $originalContainer = $this->getRepeatableContainerByOriginalIdentifier((string)$argumentKey); 335 | $argumentPath[] = $argumentKey; 336 | $copyIndexes = array_keys($argumentValue); 337 | 338 | if ( 339 | $originalContainer 340 | && count(array_filter(array_keys($copyIndexes), 'is_string')) === 0 341 | ) { 342 | $currentArgumentPath = implode('.', $argumentPath); 343 | $formValue = $this->formState->getFormValue($currentArgumentPath); 344 | if ($formValue !== null) { 345 | foreach ($formValue as $key => $_) { 346 | if (!in_array($key, $copyIndexes)) { 347 | unset($formValue[$key]); 348 | } 349 | } 350 | $this->formState->setFormValue($currentArgumentPath, $formValue); 351 | } 352 | } 353 | 354 | $this->removeDeletedRepeatableContainersFromFormValuesByRequest($argumentValue, $argumentPath); 355 | array_pop($argumentPath); 356 | } 357 | } 358 | } 359 | 360 | /** 361 | * This function fetches variants of the original form element and copies them into the 362 | * new form element. 363 | * Extendable by listening for @see CopyVariantEvent 364 | * 365 | * @param FormElementInterface $originalFormElement 366 | * @param FormElementInterface $newFormElement 367 | * @param string $newIdentifier 368 | */ 369 | protected function copyVariants( 370 | FormElementInterface $originalFormElement, 371 | FormElementInterface $newFormElement, 372 | string $newIdentifier, 373 | ): void { 374 | if (!$this->features->isFeatureEnabled('repeatableFormElements.copyVariants')) { 375 | return; 376 | } 377 | 378 | $originalVariants = $originalFormElement->getVariants(); 379 | foreach ($originalVariants as $originalIdentifier => $originalVariant) { 380 | // make sure that we only copy variants that are missing in the copied element 381 | if ($originalVariant instanceof RenderableVariant 382 | && !in_array($originalIdentifier, array_keys($newFormElement->getVariants())) 383 | ) { 384 | // variant properties are protected and class is marked internal, 385 | // so we use reflection 386 | $reflectionClass = new \ReflectionClass(RenderableVariant::class); 387 | $propOption = $reflectionClass->getProperty('options'); 388 | $propCondition = $reflectionClass->getProperty('condition'); 389 | $options = $propOption->getValue($originalVariant); 390 | $options['condition'] = $propCondition->getValue($originalVariant); 391 | $options['identifier'] = $originalIdentifier; 392 | 393 | $eventDispatcher = GeneralUtility::makeInstance(EventDispatcherInterface::class); 394 | /** @var CopyVariantEvent $event */ 395 | $event = $eventDispatcher->dispatch( 396 | new CopyVariantEvent($options, $originalFormElement, $newFormElement, $newIdentifier), 397 | ); 398 | 399 | // only add this variant, if it did not get disabled. 400 | if (!$event->isVariantEnabled()) { 401 | continue; 402 | } 403 | 404 | $options = $event->getOptions(); 405 | $newFormElement->createVariant($options); 406 | } 407 | } 408 | } 409 | 410 | } 411 | -------------------------------------------------------------------------------- /Configuration/.htaccess: -------------------------------------------------------------------------------- 1 | Order deny,allow 2 | Deny from all -------------------------------------------------------------------------------- /Configuration/Icons.php: -------------------------------------------------------------------------------- 1 | [ 9 | 'provider' => SvgIconProvider::class, 10 | 'source' => 'EXT:repeatable_form_elements/Resources/Public/Icons/t3-form-icon-repeatable-container.svg', 11 | ], 12 | ]; 13 | -------------------------------------------------------------------------------- /Configuration/JavaScriptModules.php: -------------------------------------------------------------------------------- 1 | ['form'], 5 | 'imports' => [ 6 | '@tritum/repeatable-form-elements/' => 'EXT:repeatable_form_elements/Resources/Public/JavaScript/', 7 | ], 8 | ]; 9 | -------------------------------------------------------------------------------- /Configuration/Services.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | _defaults: 3 | autowire: true 4 | autoconfigure: true 5 | public: false 6 | 7 | TRITUM\RepeatableFormElements\: 8 | resource: '../Classes/*' 9 | 10 | TRITUM\RepeatableFormElements\EventListener\AdaptVariantConditionEventListener: 11 | tags: 12 | - name: event.listener 13 | identifier: 'repeatableFormElements/copyVariants/adaptCondition' 14 | -------------------------------------------------------------------------------- /Configuration/Sets/RepeatableFormElements/config.yaml: -------------------------------------------------------------------------------- 1 | name: tritum/repeatable-form-elements 2 | label: "Form: Repeatable form elements" 3 | dependencies: 4 | - typo3/form 5 | -------------------------------------------------------------------------------- /Configuration/Sets/RepeatableFormElements/setup.typoscript: -------------------------------------------------------------------------------- 1 | @import 'EXT:repeatable_form_elements/Configuration/TypoScript/setup.typoscript' 2 | -------------------------------------------------------------------------------- /Configuration/TCA/Overrides/sys_template.php: -------------------------------------------------------------------------------- 1 | 294 | Copyright (C) 295 | 296 | This program is free software; you can redistribute it and/or modify 297 | it under the terms of the GNU General Public License as published by 298 | the Free Software Foundation; either version 2 of the License, or 299 | (at your option) any later version. 300 | 301 | This program is distributed in the hope that it will be useful, 302 | but WITHOUT ANY WARRANTY; without even the implied warranty of 303 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 304 | GNU General Public License for more details. 305 | 306 | You should have received a copy of the GNU General Public License along 307 | with this program; if not, write to the Free Software Foundation, Inc., 308 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 309 | 310 | Also add information on how to contact you by electronic and paper mail. 311 | 312 | If the program is interactive, make it output a short notice like this 313 | when it starts in an interactive mode: 314 | 315 | Gnomovision version 69, Copyright (C) year name of author 316 | Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 317 | This is free software, and you are welcome to redistribute it 318 | under certain conditions; type `show c' for details. 319 | 320 | The hypothetical commands `show w' and `show c' should show the appropriate 321 | parts of the General Public License. Of course, the commands you use may 322 | be called something other than `show w' and `show c'; they could even be 323 | mouse-clicks or menu items--whatever suits your program. 324 | 325 | You should also get your employer (if you work as a programmer) or your 326 | school, if any, to sign a "copyright disclaimer" for the program, if 327 | necessary. Here is a sample; alter the names: 328 | 329 | Yoyodyne, Inc., hereby disclaims all copyright interest in the program 330 | `Gnomovision' (which makes passes at compilers) written by James Hacker. 331 | 332 | , 1 April 1989 333 | Ty Coon, President of Vice 334 | 335 | This General Public License does not permit incorporating your program into 336 | proprietary programs. If your program is a subroutine library, you may 337 | consider it more useful to permit linking proprietary applications with the 338 | library. If this is what you want to do, use the GNU Lesser General 339 | Public License instead of this License. 340 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | ![TYPO3 extension](https://typo3-badges.dev/badge/repeatable_form_elements/extension/shields.svg) 3 | ![Total downloads](https://typo3-badges.dev/badge/repeatable_form_elements/downloads/shields.svg) 4 | ![Stability](https://typo3-badges.dev/badge/repeatable_form_elements/stability/shields.svg) 5 | ![TYPO3 versions](https://typo3-badges.dev/badge/repeatable_form_elements/typo3/shields.svg) 6 | ![Latest version](https://typo3-badges.dev/badge/repeatable_form_elements/version/shields.svg) 7 | 8 | # Custom form element "Repeatable container" 9 | 10 | This TYPO3 extension adds a custom form element "Repeatable container" to the 11 | TYPO3 form framework. It displays one/ a set of fields which can be duplicated 12 | and removed if desired. Any existing validation is copied as well. All form 13 | finishers will be aware of the copied field(s). 14 | 15 | ## Preferred installation 16 | 17 | 1. Require the extension via composer. 18 | 2. Add the site set tritum/form-element-linked-checkbox to the dependencies of 19 | your site packages site set (TYPO3 v13). Or add the static TypoScript 20 | configuration to your TypoScript template (TYPO3 v12 and TYPO3 v13). 21 | 22 | ## Usage 23 | 24 | Open the TYPO3 form editor and create a new form/ open an existing one. Add a 25 | new element to your form. The modal will list the new custom form element 26 | "Repeatable container". 27 | 28 | Add the desired fields with the favored validators to the "Repeatable container". 29 | 30 | The frontend will render the "Repeatable container" as fieldset. In addition to the 31 | included form elements it will display buttons for copying/ removing new sets of fields. 32 | 33 | The newly implemented extended version of SaveToDatabaseFinisher can be used as seen [here](Resources/Private/ExampleFormDefinitions/extended-save-to-database-finisher.form.yaml). 34 | 35 | ## Configuration 36 | 37 | To deactivate the copying of variants, the feature `repeatableFormElements.copyVariants` can be used 38 | 39 | ## Extendability 40 | 41 | The following options can be used to extend the behavior when copying. 42 | 43 | | Name | Description | 44 | |------------------|------------------------------------------------------------------| 45 | | CopyVariantEvent | Extend manipulation of copied variants or disable specific ones. | 46 | 47 | ## Credits 48 | 49 | This TYPO3 extension was created by Ralf Zimmermann (https://dreistrom.land). 50 | 51 | ## Thank you 52 | 53 | Nora Winter - "Faktenkopf" at www.faktenhaus.de - sponsored this great extension. 54 | The fine people at www.b13.de connected all the people involved. 55 | 56 | Elias Häußler - haeussler.dev - for helping with TYPO3v11 compatability and providing 57 | the beautiful [TYPO3 badges](https://typo3-badges.dev). Use them. Give him some kudos! 58 | 59 | Uwe - Hawkeye1909 - for removing jQuery as dependency. 60 | 61 | Alexander Opitz @ extrameile-gehen.de - for his work on saving repeatable elements to database. 62 | 63 | 64 | especially to all others who have contributed to the improvement of the extension. 65 | -------------------------------------------------------------------------------- /Resources/Private/ExampleFormDefinitions/extended-save-to-database-finisher.form.yaml: -------------------------------------------------------------------------------- 1 | 2 | renderingOptions: 3 | submitButtonLabel: Submit 4 | type: Form 5 | identifier: extended-save-to-database-finisher 6 | label: extended-save-to-database-finisher 7 | prototypeName: standard 8 | finishers: 9 | - 10 | options: 11 | 0: 12 | table: tt_content 13 | mode: insert 14 | elements: 15 | Example: 16 | mapOnDatabaseColumn: bodytext 17 | databaseColumnMappings: 18 | CType: 19 | value: textmedia 20 | 1: 21 | table: sys_file_reference 22 | mode: insert 23 | container: RepeatableContainer-1 24 | elements: 25 | 'imageupload-1': 26 | mapOnDatabaseColumn: uid_local 27 | 'Person': 28 | mapOnDatabaseColumn: description 29 | databaseColumnMappings: 30 | uid_foreign: 31 | value: '{ExtendedSaveToDatabase.insertedUids.0}' 32 | tablenames: 33 | value: tt_content 34 | fieldname: 35 | value: assets 36 | 2: 37 | table: tt_content 38 | mode: update 39 | whereClause: 40 | uid: '{ExtendedSaveToDatabase.insertedUids.0}' 41 | databaseColumnMappings: 42 | assets: 43 | value: '{ExtendedSaveToDatabase.countInserts.1}' 44 | identifier: ExtendedSaveToDatabase 45 | renderables: 46 | - 47 | renderingOptions: 48 | previousButtonLabel: 'Previous step' 49 | nextButtonLabel: 'Next step' 50 | type: Page 51 | identifier: page-1 52 | label: Step 53 | renderables: 54 | - 55 | defaultValue: '' 56 | type: Text 57 | identifier: Example 58 | label: ExampleTest 59 | - 60 | properties: 61 | minimumCopies: '1' 62 | maximumCopies: 10 63 | showRemoveButton: true 64 | copyButtonLabel: Copy 65 | removeButtonLabel: Remove 66 | type: RepeatableContainer 67 | identifier: RepeatableContainer-1 68 | label: RepeatableContainerTest 69 | renderables: 70 | - 71 | defaultValue: '' 72 | type: Text 73 | identifier: Person 74 | label: PersonTest 75 | - 76 | properties: 77 | saveToFileMount: '1:/user_upload/' 78 | allowedMimeTypes: 79 | - image/jpeg 80 | - image/png 81 | - image/bmp 82 | type: ImageUpload 83 | identifier: imageupload-1 84 | label: 'Image upload' 85 | - 86 | renderingOptions: 87 | previousButtonLabel: 'Previous step' 88 | nextButtonLabel: 'Next step' 89 | type: SummaryPage 90 | identifier: summarypage-1 91 | label: 'Summary step' 92 | -------------------------------------------------------------------------------- /Resources/Private/Frontend/Partials/RepeatableContainer.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 |
6 | 7 | {formvh:translateElementProperty(element: element, property: 'label')} 8 |
9 | 10 | 13 | 14 | 15 | 18 | 19 |
20 |
21 |
22 | 23 |
24 | 25 | 26 | 27 |
28 |
29 |
30 |
31 | 32 | -------------------------------------------------------------------------------- /Resources/Private/Language/Database.xlf: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |
5 | 6 | 7 | Repeatable container 8 | 9 | 10 | Repeatable container 11 | 12 | 13 | Copy button label 14 | 15 | 16 | Copy 17 | 18 | 19 | Remove button label 20 | 21 | 22 | Remove 23 | 24 | 25 | Minimum number of copies 26 | 27 | 28 | Maximum number of copies 29 | 30 | 31 | Show remove button 32 | 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /Resources/Private/Language/de.Database.xlf: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |
5 | 6 | 7 | Repeatable container 8 | Repeatable Container 9 | 10 | 11 | Repeatable container 12 | Repeatable Container 13 | 14 | 15 | Copy button label 16 | Label Kopier-Button 17 | 18 | 19 | Copy 20 | Kopieren 21 | 22 | 23 | Remove button label 24 | Label Lösch-Button 25 | 26 | 27 | Remove 28 | Entfernen 29 | 30 | 31 | Minimum number of copies 32 | Minimale Anzahl an Kopien 33 | 34 | 35 | Maximum number of copies 36 | Maximale Anzahl an Kopien 37 | 38 | 39 | Show remove button 40 | Zeige Lösch-Button 41 | 42 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /Resources/Private/Language/de.locallang.xlf: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |
5 | 6 | 7 | The maximum number of copies has been reached. 8 | Die maximale Anzahl an Kopien wurde erreicht. 9 | 10 | 11 | The minimum number of copies has not yet been reached. 12 | Die minimale Anzahl an Kopien wurde noch nicht erreicht. 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /Resources/Private/Language/locallang.xlf: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |
5 | 6 | 7 | The maximum number of copies has been reached. 8 | 9 | 10 | The minimum number of copies has not yet been reached. 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /Resources/Public/Icons/Extension.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 8 | 10 | 11 | 12 | 13 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /Resources/Public/Icons/t3-form-icon-repeatable-container.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /Resources/Public/JavaScript/backend/form-editor/view-model.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Module: @vendor/form-element-linked-checkbox/Backend/FormEditor/ViewModel.js 3 | */ 4 | 5 | import $ from 'jquery'; 6 | import * as Helper from '@typo3/form/backend/form-editor/helper.js' 7 | import {renderCheckboxTemplate} from '@typo3/form/backend/form-editor/stage-component.js' 8 | 9 | /** 10 | * @private 11 | * 12 | * @var object 13 | */ 14 | let _formEditorApp = null; 15 | 16 | /** 17 | * @private 18 | * 19 | * @return object 20 | */ 21 | function getFormEditorApp() { 22 | return _formEditorApp; 23 | }; 24 | 25 | /** 26 | * @private 27 | * 28 | * @return object 29 | */ 30 | function getPublisherSubscriber() { 31 | return getFormEditorApp().getPublisherSubscriber(); 32 | }; 33 | 34 | /** 35 | * @private 36 | * 37 | * @return object 38 | */ 39 | function getUtility() { 40 | return getFormEditorApp().getUtility(); 41 | }; 42 | 43 | /** 44 | * @private 45 | * 46 | * @param object 47 | * @return object 48 | */ 49 | function getHelper() { 50 | return Helper; 51 | }; 52 | 53 | /** 54 | * @private 55 | * 56 | * @return object 57 | */ 58 | function getCurrentlySelectedFormElement() { 59 | return getFormEditorApp().getCurrentlySelectedFormElement(); 60 | }; 61 | 62 | /** 63 | * @private 64 | * 65 | * @param mixed test 66 | * @param string message 67 | * @param int messageCode 68 | * @return void 69 | */ 70 | function assert(test, message, messageCode) { 71 | return getFormEditorApp().assert(test, message, messageCode); 72 | }; 73 | 74 | /** 75 | * @private 76 | * 77 | * @return void 78 | * @throws 1491643380 79 | */ 80 | function _helperSetup() { 81 | assert('function' === $.type(Helper.bootstrap), 82 | 'The view model helper does not implement the method "bootstrap"', 83 | 1491643380 84 | ); 85 | Helper.bootstrap(getFormEditorApp()); 86 | }; 87 | 88 | /** 89 | * @private 90 | * 91 | * @return void 92 | */ 93 | function _subscribeEvents() { 94 | getPublisherSubscriber().subscribe('view/stage/abstract/render/template/perform', function(topic, args) { 95 | if (args[0].get('type') === 'RepeatableContainer') { 96 | renderCheckboxTemplate(args[0], args[1]); 97 | } 98 | }); 99 | }; 100 | 101 | /** 102 | * @public 103 | * 104 | * @param object formEditorApp 105 | * @return void 106 | */ 107 | export function bootstrap(formEditorApp) { 108 | _formEditorApp = formEditorApp; 109 | _helperSetup(); 110 | _subscribeEvents(); 111 | }; 112 | -------------------------------------------------------------------------------- /Resources/Public/JavaScript/frontend/repeatable-container.js: -------------------------------------------------------------------------------- 1 | var ready = (callback) => { 2 | if (document.readyState != "loading") callback(); 3 | else document.addEventListener("DOMContentLoaded", callback); 4 | } 5 | ready(() => { 6 | var containerClones = {}; 7 | 8 | document.querySelectorAll('[data-repeatable-container][data-is-root]').forEach((rootElement) => { 9 | let containerClone = rootElement.cloneNode(true), 10 | containerIdentifier = rootElement.dataset['identifier'], 11 | formIdentifier = rootElement.closest('form').getAttribute('id'), 12 | copyButton = containerClone.querySelector('[data-repeatable-container][data-copy-button]'); 13 | 14 | containerClones[formIdentifier] = containerClones[formIdentifier] || {}; 15 | 16 | for (const copyElement of containerClone.querySelectorAll('[data-repeatable-container][data-is-copy]')) { 17 | copyElement.remove(); 18 | } 19 | if (copyButton !== null) { 20 | copyButton.remove(); 21 | } 22 | for (const alertElement of containerClone.querySelectorAll('[role="alert"]')) { 23 | alertElement.remove(); 24 | } 25 | 26 | containerClone.querySelectorAll('[data-repeatable-container][data-is-root]').forEach((cloneElement) => { 27 | delete cloneElement.dataset.isRoot; 28 | cloneElement.dataset.isCopy = ''; 29 | cloneElement.dataset.copyMother = cloneElement.dataset['identifier']; 30 | }); 31 | delete containerClone.dataset.isRoot; 32 | containerClone.dataset.isCopy = ''; 33 | containerClone.querySelectorAll('*').forEach((cloneChildElement) => { 34 | cloneChildElement.classList.remove('has-error'); 35 | }); 36 | 37 | let inputs = [...containerClone.querySelectorAll('input')]; 38 | inputs.filter((input) => ['checkbox', 'radio', 'hidden'].indexOf(input.getAttribute('type')) == -1).forEach((inputElement) => { 39 | inputElement.setAttribute('value', ''); 40 | }); 41 | inputs.filter((input) => ['file'].indexOf(input.getAttribute('type')) >= 0).forEach((inputElement) => { 42 | [...inputElement.parentNode.children].filter((child) => child !== inputElement).forEach((siblingElement) => { 43 | siblingElement.remove(); 44 | }); 45 | }); 46 | inputs.filter((input) => ['checkbox', 'radio'].indexOf(input.getAttribute('type')) >= 0).forEach((inputElement) => { 47 | inputElement.checked = false; 48 | }); 49 | containerClone.querySelectorAll('textarea').forEach((textareaElement) => { 50 | textareaElement.setAttribute('value', ''); 51 | }); 52 | containerClone.querySelectorAll('select').forEach((selectElement) => { 53 | let selected = selectElement.selectedOptions; 54 | for (let i = 0; i < selected.length; i++) { 55 | selected[i].removeAttribute('selected'); 56 | } 57 | selectElement.selectedIndex = -1; 58 | }); 59 | 60 | containerClones[formIdentifier][containerIdentifier] = containerClone; 61 | }); 62 | 63 | document.dispatchEvent(new CustomEvent('initialize-repeatable-container-copy-buttons', { 'detail': { 'containerClones': containerClones } })); 64 | document.dispatchEvent(new CustomEvent('initialize-repeatable-container-remove-buttons')); 65 | }); 66 | 67 | document.addEventListener('initialize-repeatable-container-copy-buttons', (copyEvent) => { 68 | let containerClones = copyEvent.detail.containerClones, 69 | getNextCopyNumber = (referenceElement, formElement) => { 70 | let highestCopyNumber = 0; 71 | 72 | formElement.querySelectorAll('[data-repeatable-container][data-copy-reference="' + referenceElement.dataset['identifier'] + '"]').forEach((copyElement) => { 73 | let copyNumber = parseInt(copyElement.dataset['identifier'].split('.').pop()); 74 | 75 | if (copyNumber > highestCopyNumber) { 76 | highestCopyNumber = copyNumber; 77 | } 78 | }); 79 | 80 | return ++highestCopyNumber; 81 | }, 82 | setRandomIds = (subject) => { 83 | let idReplacements = {}; 84 | 85 | subject.querySelectorAll('[id]').forEach((subjectIdElement) => { 86 | let id = subjectIdElement.getAttribute('id'), 87 | newId = Math.floor(Math.random() * 99999999) + Date.now(); 88 | 89 | subjectIdElement.setAttribute('id', newId); 90 | idReplacements[id] = newId; 91 | }); 92 | 93 | subject.querySelectorAll('*').forEach((subjectChildElement) => { 94 | for (let i = 0, len = subjectChildElement.attributes.length; i < len; i++) { 95 | let attributeValue = subjectChildElement.attributes[i].nodeValue; 96 | 97 | if (attributeValue in idReplacements) { 98 | subjectChildElement.attributes[i].nodeValue = idReplacements[attributeValue]; 99 | } 100 | } 101 | }); 102 | }, 103 | escapeRegExp = (subject) => { 104 | return subject.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'); 105 | }; 106 | 107 | document.querySelectorAll('[data-repeatable-container][data-copy-button]').forEach((copyBtnElement) => { 108 | let formElement = copyBtnElement.closest('form'), 109 | referenceElementIdentifier = copyBtnElement.dataset['copyButtonFor'], 110 | referenceElement = formElement.querySelector('[data-repeatable-container][data-identifier="' + referenceElementIdentifier + '"]'), 111 | elementCopies = formElement.querySelectorAll('[data-repeatable-container][data-copy-reference="' + referenceElementIdentifier + '"]'), 112 | maxCopies = referenceElement.dataset['maxCopies'] || false; 113 | 114 | if (maxCopies && elementCopies.length >= maxCopies) { 115 | copyBtnElement.disabled = true; 116 | } else { 117 | copyBtnElement.disabled = false; 118 | /* clone element to remove all event listeners */ 119 | let copyBtnElementClone = copyBtnElement.cloneNode(true); 120 | copyBtnElement.replaceWith(copyBtnElementClone); 121 | copyBtnElementClone.addEventListener('click', (clickEvent) => { 122 | clickEvent.preventDefault(); 123 | let clickElement = clickEvent.currentTarget, 124 | clickFormElement = clickElement.closest('form'), 125 | referenceElementIdentifier = clickElement.dataset['copyButtonFor'], 126 | formIdentifier = clickFormElement.getAttribute('id'), 127 | referenceElement = clickFormElement.querySelector('[data-repeatable-container][data-identifier="' + referenceElementIdentifier + '"]'), 128 | referenceElementIsRoot = 'isRoot' in referenceElement.dataset, 129 | elementCopies = clickFormElement.querySelectorAll('[data-repeatable-container][data-copy-reference="' + referenceElementIdentifier + '"]'), 130 | containerCloneIdentifier = referenceElementIsRoot ? referenceElementIdentifier : referenceElement.dataset['copyMother'], 131 | copyMotherIdentifier = containerClones[formIdentifier][containerCloneIdentifier].dataset['identifier'], 132 | newIdentifierParts = referenceElement.dataset['identifier'].split('.'), 133 | oldIdentifierNameRegex = escapeRegExp('[' + copyMotherIdentifier.split('.').join('][') + ']'), 134 | copyMotherIdentifierRegex = '(' + escapeRegExp('data-copy-mother="') + ')?' + escapeRegExp(copyMotherIdentifier), 135 | newIdentifier = undefined, 136 | newIdentifierNameRegex = undefined, 137 | containerClone = undefined, 138 | containerCloneHtml = undefined; 139 | 140 | newIdentifierParts.pop(); 141 | newIdentifierParts.push(getNextCopyNumber(referenceElement, clickFormElement)); 142 | newIdentifier = newIdentifierParts.join('.'); 143 | 144 | containerCloneHtml = containerClones[formIdentifier][containerCloneIdentifier].outerHTML; 145 | // leading, negative lookbehind ("? { 147 | return $1 ? $0 : newIdentifier; 148 | }); 149 | 150 | newIdentifierNameRegex = '[' + newIdentifierParts.join('][') + ']'; 151 | containerCloneHtml = containerCloneHtml.replace(new RegExp(oldIdentifierNameRegex, 'g'), newIdentifierNameRegex); 152 | 153 | let containerCloneWrap = document.createElement('div'); 154 | containerCloneWrap.innerHTML = containerCloneHtml; 155 | containerClone = containerCloneWrap.firstChild; 156 | containerClone.dataset.copyReference = referenceElementIdentifier; 157 | 158 | setRandomIds(containerClone); 159 | 160 | if (elementCopies.length === 0) { 161 | referenceElement.after(containerClone); 162 | } else { 163 | [...elementCopies].at(-1).after(containerClone); 164 | } 165 | 166 | // document.dispatchEvent(new CustomEvent('after-element-copy', { 'detail': { 'containerClone': containerClone } })); 167 | document.dispatchEvent(new CustomEvent('initialize-repeatable-container-copy-buttons', { 'detail': { 'containerClones': containerClones } })); 168 | document.dispatchEvent(new CustomEvent('initialize-repeatable-container-remove-buttons')); 169 | }); 170 | } 171 | }); 172 | }); 173 | 174 | document.addEventListener('initialize-repeatable-container-remove-buttons', (removeEvent) => { 175 | document.querySelectorAll('[data-repeatable-container][data-remove-button]').forEach((removeBtnElement) => { 176 | let formElement = removeBtnElement.closest('form'), 177 | referenceElementIdentifier = removeBtnElement.dataset['removeButtonFor'], 178 | referenceElement = formElement.querySelector('[data-repeatable-container][data-identifier="' + referenceElementIdentifier + '"]'), 179 | referenceElementIsRoot = 'isRoot' in referenceElement.dataset, 180 | referenceReferenceElementIdentifier = referenceElementIsRoot ? referenceElementIdentifier : referenceElement.dataset['copyReference'], 181 | referenceReferenceElement = formElement.querySelector('[data-repeatable-container][data-identifier="' + referenceReferenceElementIdentifier + '"]'), 182 | elementCopies = formElement.querySelectorAll('[data-repeatable-container][data-copy-reference="' + referenceReferenceElementIdentifier + '"]'), 183 | minCopies = referenceReferenceElement?.dataset['minCopies'] || false; 184 | 185 | if ((referenceElement.dataset['copyReference'] || false) && referenceElement.dataset['copyReference'] !== '') { 186 | if (minCopies && elementCopies.length <= minCopies) { 187 | // removeBtnElement.classList.remove('d-none'); 188 | removeBtnElement.disabled = true; 189 | } else { 190 | // removeBtnElement.classList.remove('d-none'); 191 | removeBtnElement.disabled = false; 192 | } 193 | } else { 194 | removeBtnElement.remove(); 195 | } 196 | 197 | if (removeBtnElement.disabled) { 198 | /* clone element to remove all event listeners */ 199 | removeBtnElement.replaceWith(removeBtnElement.cloneNode(true)); 200 | } else { 201 | /* clone element to remove all event listeners */ 202 | let removeBtnElementClone = removeBtnElement.cloneNode(true); 203 | removeBtnElement.replaceWith(removeBtnElementClone); 204 | removeBtnElementClone.addEventListener('click', (clickEvent) => { 205 | clickEvent.preventDefault(); 206 | 207 | let clickElement = clickEvent.currentTarget, 208 | clickFormElement = clickElement.closest('form'), 209 | referenceElementIdentifier = clickElement.dataset['removeButtonFor'], 210 | referenceElement = clickFormElement.querySelector('[data-repeatable-container][data-identifier="' + referenceElementIdentifier + '"]'), 211 | referenceElementIsRoot = 'isRoot' in referenceElement.dataset, 212 | referenceReferenceElementIdentifier = referenceElementIsRoot ? referenceElementIdentifier : referenceElement.dataset['copyReference'], 213 | referenceReferenceElement = clickFormElement.querySelector('[data-repeatable-container][data-identifier="' + referenceReferenceElementIdentifier + '"]'), 214 | elementCopies = clickFormElement.querySelectorAll('[data-repeatable-container][data-copy-reference="' + referenceReferenceElementIdentifier + '"]'), 215 | maxCopies = referenceReferenceElement?.dataset['maxCopies'] || false, 216 | copyButton = clickFormElement.querySelector('[data-repeatable-container][data-copy-button-for="' + referenceReferenceElementIdentifier + '"]'); 217 | 218 | if (maxCopies && elementCopies.length - 1 < maxCopies) { 219 | copyButton.disabled = false; 220 | } 221 | 222 | referenceElement.remove(); 223 | document.dispatchEvent(new CustomEvent('initialize-repeatable-container-remove-buttons')); 224 | }); 225 | } 226 | }); 227 | }); 228 | 229 | /* DatePicker is a jQuery UI function and we want to get rid of jQuery... */ 230 | /* document.addEventListener('after-element-copy', (afterCopyEvent) => { 231 | let containerClone = afterCopyEvent.detail.containerClone; 232 | document.querySelectorAll('[data-element-type="DatePicker"]').forEach((datePickerElement) => { 233 | var dateFormat; 234 | 235 | // if (!datePickerElement.classList.contains('hasDatepicker') && parseInt(datePickerElement.getAttribute('data-element-datepicker-enabled')) === 1) { 236 | if (!datePickerElement.classList.contains('hasDatepicker') && parseInt(datePickerElement.dataset['elementDatepickerEnabled']) === 1) { 237 | // dateFormat = datePickerElement.getAttribute('data-element-datepicker-date-format'); 238 | dateFormat = datePickerElement.dataset['elementDatepickerDateFormat']; 239 | 240 | dateFormat = dateFormat.replace('d', 'dd'); 241 | dateFormat = dateFormat.replace('j', 'o'); 242 | dateFormat = dateFormat.replace('l', 'DD'); 243 | dateFormat = dateFormat.replace('F', 'MM'); 244 | dateFormat = dateFormat.replace('m', 'mm'); 245 | dateFormat = dateFormat.replace('n', 'm'); 246 | dateFormat = dateFormat.replace('Y', 'yy'); 247 | 248 | $('#' + datePickerElement.attr('id')).datepicker({ 249 | dateFormat: dateFormat 250 | }).on('keydown', function(e) { 251 | if(e.keyCode == 8 || e.keyCode == 46) { 252 | e.preventDefault(); 253 | $.datepicker._clearDate(this); 254 | } 255 | }); 256 | } 257 | }); 258 | }); */ 259 | -------------------------------------------------------------------------------- /Resources/Public/StyleSheets/app.css: -------------------------------------------------------------------------------- 1 | .repeatable-container { 2 | padding: 0 20px; 3 | } 4 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tritum/repeatable-form-elements", 3 | "description": "Adds a new form element which allows the editor to create new container elements with any type fields in them. In the frontend, a user can create any number of new containers. This is an extension for TYPO3 CMS.", 4 | "license": "GPL-2.0-or-later", 5 | "type": "typo3-cms-extension", 6 | "authors": [ 7 | { 8 | "name": "Ralf Zimmermann", 9 | "email": "r.zimmermann@dreistrom.land", 10 | "homepage": "https://dreistrom.land", 11 | "role": "Developer" 12 | }, 13 | { 14 | "name": "Falko Linke", 15 | "email": "f.linke@dreistrom.land", 16 | "homepage": "https://dreistrom.land", 17 | "role": "Developer" 18 | }, 19 | { 20 | "name": "Elias Häußler", 21 | "email": "elias@haeussler.dev", 22 | "homepage": "https://haeussler.dev", 23 | "role": "Developer" 24 | }, 25 | { 26 | "name": "Christian Seyfferth", 27 | "email": "c.seyfferth@dreistrom.land", 28 | "homepage": "https://dreistrom.land", 29 | "role": "Developer" 30 | } 31 | ], 32 | "require": { 33 | "typo3/cms-core": "^12.4 || ^13.4", 34 | "typo3/cms-extbase": "^12.4 || ^13.4", 35 | "typo3/cms-form": "^12.4 || ^13.4" 36 | }, 37 | "autoload": { 38 | "psr-4": { 39 | "TRITUM\\RepeatableFormElements\\": "Classes/" 40 | } 41 | }, 42 | "config": { 43 | "allow-plugins": { 44 | "typo3/class-alias-loader": true, 45 | "typo3/cms-composer-installers": true 46 | }, 47 | "sort-packages": true 48 | }, 49 | "extra": { 50 | "typo3/cms": { 51 | "extension-key": "repeatable_form_elements" 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /ext_emconf.php: -------------------------------------------------------------------------------- 1 | 'Repeatable form elements', 5 | 'description' => 'Adds a new form element which allows the editor to create new container elements with any type fields in them. In the frontend, a user can create any number of new containers. This is an extension for TYPO3 CMS.', 6 | 'category' => 'fe', 7 | 'state' => 'stable', 8 | 'author' => 'Ralf Zimmermann, Elias Häußler, Christian Seyfferth', 9 | 'author_email' => 'r.zimmermann@dreistrom.land, elias@haeussler.dev, c.seyfferth@dreistrom.land', 10 | 'version' => '5.0.0', 11 | 'constraints' => [ 12 | 'depends' => [ 13 | 'typo3' => '12.4.0-13.4.99', 14 | ], 15 | 'conflicts' => [], 16 | 'suggests' => [], 17 | ], 18 | ]; 19 | -------------------------------------------------------------------------------- /ext_localconf.php: -------------------------------------------------------------------------------- 1 |