├── .gitignore ├── example.png ├── src ├── Resources │ ├── contao │ │ ├── templates │ │ │ ├── nc_fieldset_duplication_json.html5 │ │ │ ├── nc_fieldset_duplication_text.html5 │ │ │ ├── j_fieldset_duplication.html5 │ │ │ ├── js_fieldset_duplication.html5 │ │ │ ├── nc_fieldset_duplication_html.html5 │ │ │ └── form_fieldsetStart.html5 │ │ ├── languages │ │ │ ├── de │ │ │ │ ├── default.php │ │ │ │ └── tl_form_field.php │ │ │ └── en │ │ │ │ ├── default.php │ │ │ │ └── tl_form_field.php │ │ └── dca │ │ │ └── tl_form_field.php │ ├── config │ │ └── services.yml │ └── public │ │ ├── jquery.fieldset.duplication.min.js │ │ ├── js.fieldset.duplication.min.js │ │ ├── js.fieldset.duplication.js │ │ └── jquery.fieldset.duplication.js ├── ContaoFieldsetDuplication.php ├── DependencyInjection │ └── ContaoFieldsetDuplicationExtension.php ├── ContaoManager │ └── Plugin.php ├── EventListener │ ├── FormFieldDcaListener.php │ ├── LeadsListener.php │ └── FormHookListener.php ├── Helper │ └── FieldHelper.php └── Migration │ └── NotificationTokenTemplatesMigration.php ├── package.json ├── gulpfile.js ├── .github └── FUNDING.yml ├── composer.json ├── .php-cs-fixer.dist.php ├── README.md └── LICENSE /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /vendor 3 | /composer.lock 4 | .idea 5 | -------------------------------------------------------------------------------- /example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/inspiredminds/contao-fieldset-duplication/HEAD/example.png -------------------------------------------------------------------------------- /src/Resources/contao/templates/nc_fieldset_duplication_json.html5: -------------------------------------------------------------------------------- 1 | values) ?> 2 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "contao-fieldset-duplication", 3 | "main": "gulpfile.js", 4 | "dependencies": { 5 | "gulp": "^4.0.2", 6 | "gulp-rename": "^1.4.0", 7 | "gulp-uglify-es": "^3.0" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/Resources/contao/templates/nc_fieldset_duplication_text.html5: -------------------------------------------------------------------------------- 1 | values as $row): ?> 2 | $value): ?> 3 | labels[$name] ?>: 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/Resources/contao/templates/j_fieldset_duplication.html5: -------------------------------------------------------------------------------- 1 | 2 | 7 | -------------------------------------------------------------------------------- /src/Resources/contao/languages/de/default.php: -------------------------------------------------------------------------------- 1 | 2 | 11 | -------------------------------------------------------------------------------- /src/Resources/contao/templates/nc_fieldset_duplication_html.html5: -------------------------------------------------------------------------------- 1 | values): ?> 2 | 3 | 4 | 5 | values[0]) as $name): ?> 6 | 7 | 8 | 9 | 10 | 11 | values as $row): ?> 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 |
labels[$name] ?? $name ?>
20 | 21 | -------------------------------------------------------------------------------- /src/Resources/contao/templates/form_fieldsetStart.html5: -------------------------------------------------------------------------------- 1 | labelButtonAdd) { 6 | $config['buttonAdd'] = $this->labelButtonAdd; 7 | } 8 | 9 | if ($this->labelButtonRemove) { 10 | $config['buttonRemove'] = $this->labelButtonRemove; 11 | } 12 | 13 | ?> 14 | 15 | class): ?> class="class ?>" 0): ?> data-fieldset-duplication-config=""> 16 | 17 | label): ?> 18 | label ?> 19 | 20 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: fritzmg 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /src/DependencyInjection/ContaoFieldsetDuplicationExtension.php: -------------------------------------------------------------------------------- 1 | load('services.yml'); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/ContaoManager/Plugin.php: -------------------------------------------------------------------------------- 1 | setLoadAfter([ 35 | ContaoCoreBundle::class, 36 | 'conditionalformfields', 37 | 'leads', 38 | Terminal42ConditionalformfieldsBundle::class, 39 | Terminal42LeadsBundle::class, 40 | ]), 41 | ]; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/EventListener/FormFieldDcaListener.php: -------------------------------------------------------------------------------- 1 | fieldHelper = $fieldHelper; 27 | } 28 | 29 | /** 30 | * @Hook("loadDataContainer") 31 | */ 32 | public function onLoadDataContainer(string $table): void 33 | { 34 | if ('tl_form_field' !== $table) { 35 | return; 36 | } 37 | 38 | $fieldsetPalette = $this->fieldHelper->getFieldsetPalette(); 39 | 40 | PaletteManipulator::create() 41 | ->addField('allowDuplication', 'fconfig_legend', PaletteManipulator::POSITION_APPEND) 42 | ->applyToPalette($fieldsetPalette, 'tl_form_field') 43 | ; 44 | } 45 | 46 | public function templateOptions(): array 47 | { 48 | return Controller::getTemplateGroup('nc_fieldset_duplication_'); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Resources/config/services.yml: -------------------------------------------------------------------------------- 1 | services: 2 | _defaults: 3 | autoconfigure: true 4 | public: true 5 | 6 | inspiredminds.fieldsetduplication.listener.formhook: 7 | class: InspiredMinds\ContaoFieldsetDuplication\EventListener\FormHookListener 8 | arguments: 9 | - '@request_stack' 10 | - '@inspiredminds.fieldsetduplication.helper.field' 11 | tags: 12 | - { name: contao.hook, hook: loadFormField, method: onLoadFormField } 13 | - { name: contao.hook, hook: compileFormFields, method: onCompileFormFields, priority: -100} 14 | - { name: contao.hook, hook: storeFormData, method: onStoreFormData, priority: 100 } 15 | - { name: contao.hook, hook: prepareFormData, method: onPrepareFormData } 16 | 17 | InspiredMinds\ContaoFieldsetDuplication\EventListener\FormFieldDcaListener: 18 | arguments: 19 | - '@inspiredminds.fieldsetduplication.helper.field' 20 | 21 | InspiredMinds\ContaoFieldsetDuplication\EventListener\LeadsListener: 22 | autowire: true 23 | bind: 24 | $fieldHelper: '@inspiredminds.fieldsetduplication.helper.field' 25 | 26 | inspiredminds.fieldsetduplication.helper.field: 27 | class: InspiredMinds\ContaoFieldsetDuplication\Helper\FieldHelper 28 | 29 | inspiredminds.fieldsetduplication.migration.notificationtokentemplates: 30 | class: InspiredMinds\ContaoFieldsetDuplication\Migration\NotificationTokenTemplatesMigration 31 | arguments: 32 | - '@database_connection' 33 | -------------------------------------------------------------------------------- /src/Resources/contao/languages/en/tl_form_field.php: -------------------------------------------------------------------------------- 1 | form_{name}_{format}.']; 19 | $GLOBALS['TL_LANG']['tl_form_field']['notificationTokenFormat'] = ['Format', 'Define the required format.']; 20 | $GLOBALS['TL_LANG']['tl_form_field']['notificationTokenFormatTemplate'] = ['Template', 'Choose a custom template for the format.']; 21 | -------------------------------------------------------------------------------- /src/Resources/contao/languages/de/tl_form_field.php: -------------------------------------------------------------------------------- 1 | form_{name}_{format}.']; 19 | $GLOBALS['TL_LANG']['tl_form_field']['notificationTokenFormat'] = ['Format', 'Bestimmen Sie den Namen des Formats.']; 20 | $GLOBALS['TL_LANG']['tl_form_field']['notificationTokenFormatTemplate'] = ['Template', 'Bitte wählen Sie ein angepasstes Template aus.']; 21 | -------------------------------------------------------------------------------- /src/Helper/FieldHelper.php: -------------------------------------------------------------------------------- 1 | getShortVersion(), '>=4.5') ? 'fieldsetStart' : 'fieldsetfsStart'; 30 | } 31 | 32 | public function isFieldsetStart($field): bool 33 | { 34 | return 'start' === $this->getFieldsetType($field); 35 | } 36 | 37 | public function isFieldsetStop($field): bool 38 | { 39 | return 'stop' === $this->getFieldsetType($field); 40 | } 41 | 42 | public function getFieldsetType($field): ?string 43 | { 44 | if (!\is_string($field->type) || !str_contains($field->type, 'fieldset')) { 45 | return null; 46 | } 47 | 48 | try { 49 | $contaoVersion = PrettyVersions::getVersion('contao/core-bundle'); 50 | } catch (ReplacedPackageException $e) { 51 | $contaoVersion = PrettyVersions::getVersion('contao/contao'); 52 | } 53 | 54 | if (Semver::satisfies($contaoVersion->getShortVersion(), '>=4.5')) { 55 | return strtolower(substr($field->type, 8)); 56 | } 57 | 58 | return strtolower(substr($field->fsType, 2)); 59 | } 60 | 61 | public function isFieldset($field): bool 62 | { 63 | return str_contains($field->type, 'fieldset'); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/Migration/NotificationTokenTemplatesMigration.php: -------------------------------------------------------------------------------- 1 | db = $db; 27 | } 28 | 29 | public function shouldRun(): bool 30 | { 31 | $schemaManager = $this->db->getSchemaManager(); 32 | 33 | if (!$schemaManager->tablesExist(['tl_form_field'])) { 34 | return false; 35 | } 36 | 37 | $columns = $schemaManager->listTableColumns('tl_form_field'); 38 | 39 | if (!isset($columns['notificationtokentemplates'])) { 40 | return false; 41 | } 42 | 43 | return (int) $this->db->fetchOne("SELECT COUNT(*) FROM tl_form_field WHERE notificationTokenTemplates LIKE 'a:1:{i:0;%'") > 0; 44 | } 45 | 46 | public function run(): MigrationResult 47 | { 48 | foreach ($this->db->fetchAllAssociative("SELECT * FROM tl_form_field WHERE notificationTokenTemplates LIKE 'a:1:{i:0;%'") as $field) { 49 | $templates = []; 50 | 51 | foreach (StringUtil::deserialize($field['notificationTokenTemplates'], true) as $key => $template) { 52 | if (is_numeric($key)) { 53 | ++$key; 54 | } 55 | 56 | $templates[$key] = $template; 57 | } 58 | 59 | $this->db->update('tl_form_field', ['notificationTokenTemplates' => serialize($templates)], ['id' => $field['id']]); 60 | } 61 | 62 | return $this->createResult(true); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "inspiredminds/contao-fieldset-duplication", 3 | "description": "Contao extension to allow the duplication of form fieldsets in the front end by the user for additional input fields.", 4 | "keywords": ["contao", "fieldset", "form", "duplication"], 5 | "type": "contao-bundle", 6 | "homepage": "https://github.com/inspiredminds/contao-fieldset-duplication", 7 | "license": "LGPL-3.0-or-later", 8 | "authors": [ 9 | { 10 | "name":"Fritz Michael Gschwantner", 11 | "homepage":"http://www.inspiredminds.at", 12 | "email":"fmg@inspiredminds.at", 13 | "role":"Developer" 14 | } 15 | ], 16 | "support": { 17 | "email": "fmg@inspiredminds.at", 18 | "issues": "https://github.com/inspiredminds/contao-fieldset-duplication/issues", 19 | "source": "https://github.com/inspiredminds/contao-fieldset-duplication", 20 | "forum": "https://community.contao.org/de" 21 | }, 22 | "funding": [ 23 | { 24 | "type": "github", 25 | "url": "https://github.com/sponsors/fritzmg" 26 | } 27 | ], 28 | "require":{ 29 | "php": ">=7.4", 30 | "ext-json": "*", 31 | "contao/core-bundle": "^4.9 || ^5.0", 32 | "composer/semver": "^1.0 || ^2.0 || ^3.0", 33 | "doctrine/dbal": "^2.11 || ^3.0", 34 | "jean85/pretty-package-versions": "^1.0 || ^2.0", 35 | "mvo/contao-group-widget": "^1.3", 36 | "symfony/service-contracts": "^1.0 || ^2.0 || ^3.0", 37 | "symfony/polyfill-php80": "^1.29" 38 | }, 39 | "require-dev": { 40 | "contao/manager-plugin": "^2.0", 41 | "friendsofphp/php-cs-fixer": "^3.0", 42 | "terminal42/contao-leads": "^1.4 || ^3.0", 43 | "terminal42/contao-mp_forms": "^4.4" 44 | }, 45 | "autoload": { 46 | "psr-4": { 47 | "InspiredMinds\\ContaoFieldsetDuplication\\": "src/" 48 | } 49 | }, 50 | "extra": { 51 | "contao-manager-plugin": "InspiredMinds\\ContaoFieldsetDuplication\\ContaoManager\\Plugin" 52 | }, 53 | "config": { 54 | "allow-plugins": { 55 | "contao-components/installer": true, 56 | "contao/manager-plugin": true, 57 | "php-http/discovery": false, 58 | "contao-community-alliance/composer-plugin": true 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /.php-cs-fixer.dist.php: -------------------------------------------------------------------------------- 1 | exclude('Fixtures') 13 | ->in([ 14 | __DIR__.'/src', 15 | ]) 16 | ; 17 | 18 | return (new PhpCsFixer\Config()) 19 | ->setRules([ 20 | '@Symfony' => true, 21 | '@Symfony:risky' => true, 22 | '@PHP71Migration' => true, 23 | '@PHP71Migration:risky' => true, 24 | '@PHPUnit60Migration:risky' => true, 25 | 'align_multiline_comment' => true, 26 | 'array_indentation' => true, 27 | 'array_syntax' => ['syntax' => 'short'], 28 | 'combine_consecutive_issets' => true, 29 | 'combine_consecutive_unsets' => true, 30 | 'comment_to_phpdoc' => true, 31 | 'compact_nullable_typehint' => true, 32 | 'escape_implicit_backslashes' => true, 33 | 'fully_qualified_strict_types' => true, 34 | 'general_phpdoc_annotation_remove' => [ 35 | 'annotations' => [ 36 | 'author', 37 | 'expectedException', 38 | 'expectedExceptionMessage', 39 | ], 40 | ], 41 | 'header_comment' => ['header' => $header], 42 | 'heredoc_to_nowdoc' => true, 43 | 'linebreak_after_opening_tag' => true, 44 | 'list_syntax' => ['syntax' => 'short'], 45 | 'multiline_comment_opening_closing' => true, 46 | 'multiline_whitespace_before_semicolons' => [ 47 | 'strategy' => 'new_line_for_chained_calls', 48 | ], 49 | 'native_function_invocation' => [ 50 | 'include' => ['@compiler_optimized'], 51 | ], 52 | 'no_alternative_syntax' => true, 53 | 'no_binary_string' => true, 54 | 'no_null_property_initialization' => true, 55 | 'no_superfluous_elseif' => true, 56 | 'no_superfluous_phpdoc_tags' => true, 57 | 'no_unreachable_default_argument_value' => true, 58 | 'no_useless_else' => true, 59 | 'no_useless_return' => true, 60 | 'ordered_class_elements' => true, 61 | 'ordered_imports' => true, 62 | 'php_unit_strict' => true, 63 | 'phpdoc_add_missing_param_annotation' => true, 64 | 'phpdoc_order' => true, 65 | 'phpdoc_trim_consecutive_blank_line_separation' => true, 66 | 'phpdoc_types_order' => [ 67 | 'null_adjustment' => 'always_last', 68 | 'sort_algorithm' => 'none', 69 | ], 70 | 'return_assignment' => true, 71 | 'strict_comparison' => true, 72 | 'strict_param' => true, 73 | 'string_line_ending' => true, 74 | 'void_return' => true, 75 | ]) 76 | ->setFinder($finder) 77 | ->setRiskyAllowed(true) 78 | ->setUsingCache(false) 79 | ; 80 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![](https://img.shields.io/packagist/v/inspiredminds/contao-fieldset-duplication.svg)](https://packagist.org/packages/inspiredminds/contao-fieldset-duplication) 2 | [![](https://img.shields.io/packagist/dt/inspiredminds/contao-fieldset-duplication.svg)](https://packagist.org/packages/inspiredminds/contao-fieldset-duplication) 3 | 4 | Contao Fieldset Duplication 5 | =================== 6 | 7 | Contao extension to allow the duplication of form fieldsets in the front end by 8 | the user for additional input fields. 9 | 10 | ![Example screenshot of the front end](https://raw.githubusercontent.com/inspiredminds/contao-fieldset-duplication/master/example.png) 11 | 12 | You need to enable the `js_fieldset_duplication` template in your page layout. 13 | The following options can be changed: 14 | ```html 15 | 16 | 34 | ``` 35 | If you want to store the additional data in your database table (using the form 36 | generator's ability to store the data in the database), you need to add a column 37 | called `fieldset_duplicates` to your target table. This column will then contain 38 | the additionally submitted fields in a JSON encoded object. 39 | 40 | > [!NOTE] 41 | > Version `2.1.0` introduced the `js_fieldset_duplication` template. There also exists a jQuery version 42 | under the name `j_fieldset_duplication` from previous versions of this extension for BC purposes. Make sure to _not_ 43 | enable both these templates. 44 | 45 | Notification tokens 46 | ------------------- 47 | 48 | If you need your fieldset rendered as notification tokens, you can define notification token formats. Just define the fieldset name, a format name and select a template. The fieldset will be available at token `form_{NAME}_{FORMAT}` (`{NAME}_{FORMAT}` if you don't use the notification center). 49 | 50 | The following templates are shipped with this extension: 51 | 52 | * *nc_fieldset_duplication_text*: Renders the fieldset data as `label: value` pairs 53 | * *nc_fieldset_duplication_html*: Renders the fieldset data as html table 54 | * *nc_fieldset_duplication_json*: Renders the fieldset data as json string 55 | -------------------------------------------------------------------------------- /src/Resources/contao/dca/tl_form_field.php: -------------------------------------------------------------------------------- 1 | true, 20 | 'inputType' => 'checkbox', 21 | 'eval' => ['tl_class' => 'clr w50', 'submitOnChange' => true], 22 | 'sql' => "char(1) NOT NULL default ''", 23 | ]; 24 | 25 | $GLOBALS['TL_DCA']['tl_form_field']['fields']['maxDuplicationRows'] = [ 26 | 'exclude' => true, 27 | 'inputType' => 'text', 28 | 'eval' => ['rgxp' => 'digit', 'tl_class' => 'w50'], 29 | 'sql' => "varchar(10) NOT NULL default ''", 30 | ]; 31 | 32 | $GLOBALS['TL_DCA']['tl_form_field']['fields']['labelButtonAdd'] = [ 33 | 'exclude' => true, 34 | 'inputType' => 'text', 35 | 'eval' => ['maxlength' => 64, 'tl_class' => 'w50'], 36 | 'sql' => "varchar(64) NOT NULL default ''", 37 | ]; 38 | 39 | $GLOBALS['TL_DCA']['tl_form_field']['fields']['labelButtonRemove'] = [ 40 | 'exclude' => true, 41 | 'inputType' => 'text', 42 | 'eval' => ['maxlength' => 64, 'tl_class' => 'w50'], 43 | 'sql' => "varchar(64) NOT NULL default ''", 44 | ]; 45 | 46 | $GLOBALS['TL_DCA']['tl_form_field']['fields']['doNotCopyExistingValues'] = [ 47 | 'exclude' => true, 48 | 'inputType' => 'checkbox', 49 | 'eval' => ['tl_class' => 'w50'], 50 | 'sql' => ['type' => 'boolean', 'default' => false], 51 | ]; 52 | 53 | $GLOBALS['TL_DCA']['tl_form_field']['fields']['notificationTokenTemplates'] = [ 54 | 'label' => &$GLOBALS['TL_LANG']['tl_form_field']['notificationTokenTemplates'], 55 | 'exclude' => true, 56 | 'inputType' => 'group', 57 | 'palette' => ['format', 'template'], 58 | 'eval' => ['tl_class' => 'clr'], 59 | 'fields' => [ 60 | 'format' => [ 61 | 'label' => &$GLOBALS['TL_LANG']['tl_form_field']['notificationTokenFormat'], 62 | 'inputType' => 'text', 63 | 'eval' => ['tl_class' => 'w50'], 64 | ], 65 | 'template' => [ 66 | 'label' => &$GLOBALS['TL_LANG']['tl_form_field']['notificationTokenFormatTemplate'], 67 | 'inputType' => 'select', 68 | 'options_callback' => [FormFieldDcaListener::class, 'templateOptions'], 69 | 'eval' => ['includeBlankOption' => true, 'chosen' => true, 'tl_class' => 'w50'], 70 | ], 71 | ], 72 | 'sql' => ['type' => 'blob', 'length' => 65535, 'notnull' => false], 73 | ]; 74 | -------------------------------------------------------------------------------- /src/Resources/public/jquery.fieldset.duplication.min.js: -------------------------------------------------------------------------------- 1 | !function(t){"use strict";t.fn.fieldsetDuplication=function(e){var n={prepend:!1,buttonAdd:"+",buttonRemove:"×",widgetSelector:".widget"};return t(this).each((function(i,a){var o=t(this),d=null,l=null,c=null,u=0,r=t.extend({},n,e,o.data("fieldset-duplication-config")||{}),s=o.attr("class").split(/\s+/);t.each(s,(function(t,e){if(e.match(/duplicate-fieldset-/))return l="."+e,!1})),t.each(s,(function(t,e){if(e.match(/duplicate-fieldset-maxRows-/))return c=e.substring("duplicate-fieldset-maxRows-".length),!1}));const f=Array.from(document.querySelectorAll(l+' [name*="_duplicate_"]')).pop();f&&(u=parseInt(f.getAttribute("name").slice(-1),10));var p=function(){(d=t(l)).each((function(e,n){var i=t(this);i.removeClass("last"),i.removeClass("first"),0==e&&i.addClass("first"),e==d.length-1&&i.addClass("last"),v(i),i.hasClass("duplicate")?i.find(".duplication-buttons button.duplication-button--remove").show():i.find(".duplication-buttons button.duplication-button--remove").hide()}))},b=function(e){e.find(".duplication-buttons button.duplication-button--add").off("click").on("click",(function(e){return e.preventDefault(),function(e){if(null!=c&&d.length>=c)return void t(document).trigger("fieldset-clone-rejected",[e,d,c]);var n=e.clone();u++;const i={};n.find("input[name], select[name], textarea[name]").each((function(){var n=t(this);n.removeClass("error");var a=n.attr("id");if(void 0!==a){(l=a.indexOf("_duplicate_"))>=0&&(a=a.substr(0,l));var o=a+"_duplicate_"+u;n.closest(r.widgetSelector).find('label[for="'+n.attr("id")+'"]').each((function(){var e=t(this);e.attr("for",o),void 0!==e.attr("id")&&e.attr("id",e.attr("id")+"_duplicate_"+u)})),n.attr("id",o)}var d=n.attr("name");if(void 0!==d){var l,c=d.endsWith("[]");c&&(d=d.substring(0,d.length-2)),(l=d.indexOf("_duplicate_"))>=0&&(d=d.substr(0,l));var s=d+"_duplicate_"+u;c&&(s+="[]"),n.attr("name",s),i[d]=s}var f=n.attr("value");"checkbox"!==n.attr("type")&&"radio"!==n.attr("type")?n.val()&&e.hasClass("duplicate-fieldset-donotcopy")&&n.val(f):n.not("[checked]").prop("checked",!1)})),n.find("fieldset[data-cff-condition]").each(((t,e)=>{let n=e.dataset.cffCondition;for(const[t,e]of Object.entries(i))n=n.replaceAll(t,e);e.dataset.cffCondition=n})),n.find("label").removeClass("error"),n.find("p.error").remove(),n.find(r.widgetSelector).removeClass("error"),n.addClass("duplicate"),b(n),e.after(n),p(),null!=c&&d.length>=c&&d.each((function(e,n){var i=t(this);i.find(".duplication-button--add").addClass("disabled"),i.find(".duplication-button--add").attr("disabled","disabled")})),t(document).trigger("fieldset-cloned",[n])}(t(this).closest("fieldset")),!1})),e.hasClass("duplicate")&&e.find(".duplication-buttons button.duplication-button--remove").off("click").on("click",(function(e){return e.preventDefault(),function(t){d.length>1&&t.hasClass("duplicate")&&(t.remove(),p())}(t(this).closest("fieldset")),!1}))},v=function(e){e.find(".duplication-buttons").remove();var n=t('
'),i=t('");n.append(i);var a=t('");n.append(a),r.prepend?e.prepend(n):e.append(n),b(e)};p()})),this}}(jQuery); -------------------------------------------------------------------------------- /src/Resources/public/js.fieldset.duplication.min.js: -------------------------------------------------------------------------------- 1 | !function(){"use strict";window.fieldsetDuplication=function(t,e){var n=Object.assign({},{prepend:!1,buttonAdd:"+",buttonRemove:"×",widgetSelector:".widget"},e,JSON.parse(t.dataset.fieldsetDuplicationConfig||"{}")),i=null,o=null,l=null,c=0,a=t.className.split(/\s+/);a.some((function(t){if(t.match(/duplicate-fieldset-/))return o="."+t,!0})),a.some((function(t){if(t.match(/duplicate-fieldset-maxRows-/))return l=t.substring("duplicate-fieldset-maxRows-".length),!0}));var d=document.querySelector(o+' [name*="_duplicate_"]:last-child');function u(){(i=document.querySelectorAll(o)).forEach((function(t,e){t.classList.remove("last","first"),0===e&&t.classList.add("first"),e===i.length-1&&t.classList.add("last"),function(t){var e;(e=t.querySelector(".duplication-buttons"))&&e.remove();(e=document.createElement("div")).classList.add("duplication-buttons");var i=document.createElement("button");i.type="button",i.classList.add("duplication-button","duplication-button--add"),i.textContent=n.buttonAdd,e.appendChild(i);var o=document.createElement("button");o.type="button",o.classList.add("duplication-button","duplication-button--remove"),o.textContent=n.buttonRemove,e.appendChild(o),n.prepend?t.insertBefore(e,t.firstChild):t.appendChild(e);r(t)}(t),t.classList.contains("duplicate")?t.querySelector(".duplication-buttons button.duplication-button--remove").style.display="block":t.querySelector(".duplication-buttons button.duplication-button--remove").style.display="none"}))}function r(t){t.querySelector(".duplication-buttons button.duplication-button--add").addEventListener("click",(function(e){return e.preventDefault(),function(t){if(null!==l&&i.length>=l)document.dispatchEvent(new Event("fieldset-clone-rejected"));else{var e=t.cloneNode(!0);c++;var o={};e.querySelectorAll("input[name], select[name], textarea[name]").forEach((function(e){e.classList.remove("error");var i=e.id;if(void 0!==i){(d=i.indexOf("_duplicate_"))>=0&&(i=i.substr(0,d));var l=i+"_duplicate_"+c;e.closest(n.widgetSelector).querySelectorAll('label[for="'+e.id+'"]').forEach((function(t){t.setAttribute("for",l),void 0!==t.id&&(t.id=t.id+"_duplicate_"+c)})),e.id=l}var a=e.name;if(void 0!==a){var d,u=a.endsWith("[]");u&&(a=a.substring(0,a.length-2)),(d=a.indexOf("_duplicate_"))>=0&&(a=a.substr(0,d));var r=a+"_duplicate_"+c;u&&(r+="[]"),e.name=r,o[a]=r}var s=e.getAttribute("value");"checkbox"!==e.type&&"radio"!==e.type?e.value&&t.classList.contains("duplicate-fieldset-donotcopy")&&(e.value=s):e.checked=!1})),e.querySelectorAll("fieldset[data-cff-condition]").forEach((function(t){let e=t.dataset.cffCondition;for(const[t,n]of Object.entries(o))e=e.replaceAll(t,n);t.dataset.cffCondition=e})),e.querySelectorAll("label").forEach((function(t){t.classList.remove("error")})),e.querySelectorAll("p.error").forEach((function(t){t.remove()})),e.querySelectorAll(n.widgetSelector).forEach((function(t){t.classList.remove("error")})),e.classList.add("duplicate"),r(e),t.after(e),u(),null!==l&&i.length>=l&&i.forEach((function(t){t.querySelector(".duplication-button--add").classList.add("disabled"),t.querySelector(".duplication-button--add").setAttribute("disabled","disabled")})),document.dispatchEvent(new Event("fieldset-cloned"))}}(t),!1})),t.classList.contains("duplicate")&&t.querySelector(".duplication-buttons button.duplication-button--remove").addEventListener("click",(function(e){return e.preventDefault(),function(t){i.length>1&&t.classList.contains("duplicate")&&(t.remove(),u())}(t),!1}))}d&&(c=parseInt(d.getAttribute("name").slice(-1),10)),u()}}(); -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU LESSER GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | 9 | This version of the GNU Lesser General Public License incorporates 10 | the terms and conditions of version 3 of the GNU General Public 11 | License, supplemented by the additional permissions listed below. 12 | 13 | 0. Additional Definitions. 14 | 15 | As used herein, "this License" refers to version 3 of the GNU Lesser 16 | General Public License, and the "GNU GPL" refers to version 3 of the GNU 17 | General Public License. 18 | 19 | "The Library" refers to a covered work governed by this License, 20 | other than an Application or a Combined Work as defined below. 21 | 22 | An "Application" is any work that makes use of an interface provided 23 | by the Library, but which is not otherwise based on the Library. 24 | Defining a subclass of a class defined by the Library is deemed a mode 25 | of using an interface provided by the Library. 26 | 27 | A "Combined Work" is a work produced by combining or linking an 28 | Application with the Library. The particular version of the Library 29 | with which the Combined Work was made is also called the "Linked 30 | Version". 31 | 32 | The "Minimal Corresponding Source" for a Combined Work means the 33 | Corresponding Source for the Combined Work, excluding any source code 34 | for portions of the Combined Work that, considered in isolation, are 35 | based on the Application, and not on the Linked Version. 36 | 37 | The "Corresponding Application Code" for a Combined Work means the 38 | object code and/or source code for the Application, including any data 39 | and utility programs needed for reproducing the Combined Work from the 40 | Application, but excluding the System Libraries of the Combined Work. 41 | 42 | 1. Exception to Section 3 of the GNU GPL. 43 | 44 | You may convey a covered work under sections 3 and 4 of this License 45 | without being bound by section 3 of the GNU GPL. 46 | 47 | 2. Conveying Modified Versions. 48 | 49 | If you modify a copy of the Library, and, in your modifications, a 50 | facility refers to a function or data to be supplied by an Application 51 | that uses the facility (other than as an argument passed when the 52 | facility is invoked), then you may convey a copy of the modified 53 | version: 54 | 55 | a) under this License, provided that you make a good faith effort to 56 | ensure that, in the event an Application does not supply the 57 | function or data, the facility still operates, and performs 58 | whatever part of its purpose remains meaningful, or 59 | 60 | b) under the GNU GPL, with none of the additional permissions of 61 | this License applicable to that copy. 62 | 63 | 3. Object Code Incorporating Material from Library Header Files. 64 | 65 | The object code form of an Application may incorporate material from 66 | a header file that is part of the Library. You may convey such object 67 | code under terms of your choice, provided that, if the incorporated 68 | material is not limited to numerical parameters, data structure 69 | layouts and accessors, or small macros, inline functions and templates 70 | (ten or fewer lines in length), you do both of the following: 71 | 72 | a) Give prominent notice with each copy of the object code that the 73 | Library is used in it and that the Library and its use are 74 | covered by this License. 75 | 76 | b) Accompany the object code with a copy of the GNU GPL and this license 77 | document. 78 | 79 | 4. Combined Works. 80 | 81 | You may convey a Combined Work under terms of your choice that, 82 | taken together, effectively do not restrict modification of the 83 | portions of the Library contained in the Combined Work and reverse 84 | engineering for debugging such modifications, if you also do each of 85 | the following: 86 | 87 | a) Give prominent notice with each copy of the Combined Work that 88 | the Library is used in it and that the Library and its use are 89 | covered by this License. 90 | 91 | b) Accompany the Combined Work with a copy of the GNU GPL and this license 92 | document. 93 | 94 | c) For a Combined Work that displays copyright notices during 95 | execution, include the copyright notice for the Library among 96 | these notices, as well as a reference directing the user to the 97 | copies of the GNU GPL and this license document. 98 | 99 | d) Do one of the following: 100 | 101 | 0) Convey the Minimal Corresponding Source under the terms of this 102 | License, and the Corresponding Application Code in a form 103 | suitable for, and under terms that permit, the user to 104 | recombine or relink the Application with a modified version of 105 | the Linked Version to produce a modified Combined Work, in the 106 | manner specified by section 6 of the GNU GPL for conveying 107 | Corresponding Source. 108 | 109 | 1) Use a suitable shared library mechanism for linking with the 110 | Library. A suitable mechanism is one that (a) uses at run time 111 | a copy of the Library already present on the user's computer 112 | system, and (b) will operate properly with a modified version 113 | of the Library that is interface-compatible with the Linked 114 | Version. 115 | 116 | e) Provide Installation Information, but only if you would otherwise 117 | be required to provide such information under section 6 of the 118 | GNU GPL, and only to the extent that such information is 119 | necessary to install and execute a modified version of the 120 | Combined Work produced by recombining or relinking the 121 | Application with a modified version of the Linked Version. (If 122 | you use option 4d0, the Installation Information must accompany 123 | the Minimal Corresponding Source and Corresponding Application 124 | Code. If you use option 4d1, you must provide the Installation 125 | Information in the manner specified by section 6 of the GNU GPL 126 | for conveying Corresponding Source.) 127 | 128 | 5. Combined Libraries. 129 | 130 | You may place library facilities that are a work based on the 131 | Library side by side in a single library together with other library 132 | facilities that are not Applications and are not covered by this 133 | License, and convey such a combined library under terms of your 134 | choice, if you do both of the following: 135 | 136 | a) Accompany the combined library with a copy of the same work based 137 | on the Library, uncombined with any other library facilities, 138 | conveyed under the terms of this License. 139 | 140 | b) Give prominent notice with the combined library that part of it 141 | is a work based on the Library, and explaining where to find the 142 | accompanying uncombined form of the same work. 143 | 144 | 6. Revised Versions of the GNU Lesser General Public License. 145 | 146 | The Free Software Foundation may publish revised and/or new versions 147 | of the GNU Lesser General Public License from time to time. Such new 148 | versions will be similar in spirit to the present version, but may 149 | differ in detail to address new problems or concerns. 150 | 151 | Each version is given a distinguishing version number. If the 152 | Library as you received it specifies that a certain numbered version 153 | of the GNU Lesser General Public License "or any later version" 154 | applies to it, you have the option of following the terms and 155 | conditions either of that published version or of any later version 156 | published by the Free Software Foundation. If the Library as you 157 | received it does not specify a version number of the GNU Lesser 158 | General Public License, you may choose any version of the GNU Lesser 159 | General Public License ever published by the Free Software Foundation. 160 | 161 | If the Library as you received it specifies that a proxy can decide 162 | whether future versions of the GNU Lesser General Public License shall 163 | apply, that proxy's public statement of acceptance of any version is 164 | permanent authorization for you to choose that version for the 165 | Library. 166 | -------------------------------------------------------------------------------- /src/EventListener/LeadsListener.php: -------------------------------------------------------------------------------- 1 | connection = $connection; 33 | $this->fieldHelper = $fieldHelper; 34 | $this->container = $container; 35 | } 36 | 37 | /** 38 | * @Hook("loadDataContainer") 39 | */ 40 | public function onLoadDataContainer(string $table): void 41 | { 42 | if ('tl_lead_data' === $table) { 43 | $GLOBALS['TL_DCA']['tl_lead_data']['fields']['fieldset_data'] = [ 44 | 'sql' => ['type' => 'blob', 'notnull' => false], 45 | ]; 46 | } 47 | } 48 | 49 | /** 50 | * @Hook("processFormData") 51 | */ 52 | public function onProcessFormData(array $postData, array $formConfig, $files): void 53 | { 54 | if (!class_exists(Terminal42LeadsBundle::class) || !$formConfig['leadEnabled']) { 55 | return; 56 | } 57 | 58 | $result = $this->connection->createQueryBuilder()->select('id')->from('tl_lead') 59 | ->where('form_id=:form_id') 60 | ->andWhere('tstamp>:tstamp') 61 | ->andWhere('post_data=:post_data') 62 | ->setParameter('form_id', $formConfig['id']) 63 | ->setParameter('tstamp', time() - 60) 64 | ->setParameter('post_data', serialize($postData)) 65 | ->executeQuery() 66 | ->fetchOne() 67 | ; 68 | 69 | if (false !== $result) { 70 | $this->storeDuplicateFields($formConfig, $postData, $result, 3); 71 | } 72 | } 73 | 74 | /** 75 | * @Hook("storeLeadsData") 76 | */ 77 | public function onStoreLeadsData(array $arrPost, array $form, ?array $arrFiles, int $intLead): void 78 | { 79 | $this->storeDuplicateFields($form, $arrPost, $intLead); 80 | } 81 | 82 | public function getDuplicateFields(array $allFields): array 83 | { 84 | static $duplicateFields = null; 85 | 86 | if (!\is_array($duplicateFields)) { 87 | $duplicateFields = []; 88 | $fieldsetGroup = null; 89 | 90 | foreach ($allFields as $field) { 91 | if ($this->fieldHelper->isFieldsetStart((object) $field) && $field['allowDuplication'] && $field['leadStore']) { 92 | $fieldsetGroup = $field['name']; 93 | 94 | $duplicateFields[$fieldsetGroup] = [ 95 | 'fieldset' => $field, 96 | 'fields' => [], 97 | ]; 98 | 99 | continue; 100 | } 101 | 102 | if ($this->fieldHelper->isFieldsetStop((object) $field)) { 103 | $fieldsetGroup = null; 104 | continue; 105 | } 106 | 107 | if (null !== $fieldsetGroup) { 108 | $duplicateFields[$fieldsetGroup]['fields'][] = $field; 109 | } 110 | } 111 | } 112 | 113 | return $duplicateFields; 114 | } 115 | 116 | public static function getSubscribedServices(): array 117 | { 118 | $services = []; 119 | 120 | if (class_exists(Formatter::class)) { 121 | $services[Formatter::class] = '?'.Formatter::class; 122 | } 123 | 124 | return $services; 125 | } 126 | 127 | private function storeDuplicateFields(array $form, array $postData, int $leadId, int $leadsVersion = 1): void 128 | { 129 | $mainIdFieldName = 'master_id'; 130 | $mainFieldName = 'leadMaster'; 131 | 132 | if (3 === $leadsVersion) { 133 | $mainIdFieldName = 'main_id'; 134 | $mainFieldName = 'leadMain'; 135 | } 136 | 137 | // Fetch master form fields 138 | if ($form[$mainFieldName] > 0) { 139 | $leadFields = $this->connection->fetchAllAssociative( 140 | 'SELECT f2.*, f1.id AS '.$mainIdFieldName.', f1.name AS postName FROM tl_form_field f1 LEFT JOIN tl_form_field f2 ON f1.leadStore=f2.id WHERE f1.pid=? AND f1.leadStore>0 AND (f2.leadStore=? OR f2.type=? OR f2.type=?) AND f1.invisible=? ORDER BY f2.sorting', 141 | [$form['id'], 1, 'fieldsetStart', 'fieldsetStop', ''] 142 | ); 143 | } else { 144 | $leadFields = $this->connection->fetchAllAssociative( 145 | 'SELECT *, id AS '.$mainIdFieldName.', name AS postName FROM tl_form_field WHERE pid=? AND (leadStore=? OR type=? OR type=?) AND invisible=? ORDER BY sorting', 146 | [$form['id'], 1, 'fieldsetStart', 'fieldsetStop', ''] 147 | ); 148 | } 149 | 150 | $time = time(); 151 | 152 | foreach ($this->getDuplicateFields($leadFields) as $fieldset) { 153 | $fieldsetFields = []; 154 | 155 | // Collect the fields 156 | foreach ($fieldset['fields'] as $field) { 157 | foreach ($postData as $name => $value) { 158 | if (preg_match('/^('.preg_quote($field['name']).')(_duplicate_(\d+))?$/', $name, $matches)) { 159 | $index = (int) ($matches[3] ?? 0) + 1; 160 | if (class_exists(Formatter::class) && $this->container->has(Formatter::class)) { 161 | $fieldLabel = $this->container->get(Formatter::class)->dcaLabelFromArray($field); 162 | $fieldValue = $this->container->get(Formatter::class)->dcaValueFromArray($field, $value); 163 | } elseif (class_exists(Format::class)) { 164 | $fieldLabel = Format::dcaLabelFromArray($field); 165 | $fieldValue = Format::dcaValueFromArray($field, $value); 166 | } else { 167 | continue; 168 | } 169 | 170 | $fieldsetFields[$index][$field['name']] = [ 171 | 'label' => $fieldLabel, 172 | 'value' => $fieldValue, 173 | 'raw' => $value, 174 | ]; 175 | } 176 | } 177 | } 178 | 179 | $label = []; 180 | 181 | // Generate the label 182 | foreach ($fieldsetFields as $index => $fields) { 183 | foreach ($fields as $value) { 184 | $label[] = sprintf('%d. %s: %s', $index, $value['label'], $value['value']); 185 | } 186 | } 187 | 188 | if (\count($fieldsetFields) > 0) { 189 | $this->connection->insert('tl_lead_data', [ 190 | 'pid' => $leadId, 191 | 'sorting' => $fieldset['fieldset']['sorting'], 192 | 'tstamp' => $time, 193 | $mainIdFieldName => $fieldset['fieldset'][$mainIdFieldName], 194 | 'field_id' => $fieldset['fieldset']['id'], 195 | 'name' => $fieldset['fieldset']['name'], 196 | 'value' => serialize($label), 197 | 'label' => implode("\n", $label), 198 | 'fieldset_data' => serialize($fieldsetFields), 199 | ]); 200 | } 201 | 202 | // Remove the original fields that were duplicated 203 | foreach ($fieldset['fields'] as $field) { 204 | $this->connection->delete('tl_lead_data', ['pid' => $leadId, 'name' => $field['name']]); 205 | } 206 | } 207 | } 208 | } 209 | -------------------------------------------------------------------------------- /src/Resources/public/js.fieldset.duplication.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | "use strict"; 3 | 4 | function fieldsetDuplication(element, settings) { 5 | var defaults = { 6 | prepend: false, 7 | buttonAdd: '+', 8 | buttonRemove: '\u00D7', 9 | widgetSelector: '.widget', 10 | }; 11 | 12 | var options = Object.assign({}, defaults, settings, JSON.parse(element.dataset.fieldsetDuplicationConfig || '{}')); 13 | 14 | var original = element; 15 | var fieldsets = null; 16 | var selector = null; 17 | var maxRows = null; 18 | var duplicateIndex = 0; 19 | 20 | // Determine the fieldset group selector 21 | var classList = original.className.split(/\s+/); 22 | classList.some(function(item) { 23 | if (item.match(/duplicate-fieldset-/)) { 24 | selector = '.' + item; 25 | return true; 26 | } 27 | }); 28 | 29 | // Determine the max rows configuration 30 | classList.some(function(item) { 31 | if (item.match(/duplicate-fieldset-maxRows-/)) { 32 | maxRows = item.substring("duplicate-fieldset-maxRows-".length); 33 | return true; 34 | } 35 | }); 36 | 37 | // Determine the current duplicate index 38 | var lastDuplicateField = document.querySelector(selector + ' [name*="_duplicate_"]:last-child'); 39 | 40 | if (lastDuplicateField) { 41 | duplicateIndex = parseInt(lastDuplicateField.getAttribute('name').slice(-1), 10); 42 | } 43 | 44 | function updateFieldsets() { 45 | fieldsets = document.querySelectorAll(selector); 46 | fieldsets.forEach(function(fieldset, i) { 47 | fieldset.classList.remove('last', 'first'); 48 | if (i === 0) fieldset.classList.add('first'); 49 | if (i === fieldsets.length - 1) fieldset.classList.add('last'); 50 | addButtons(fieldset); 51 | if (fieldset.classList.contains('duplicate')) { 52 | fieldset.querySelector('.duplication-buttons button.duplication-button--remove').style.display = 'block'; 53 | } else { 54 | fieldset.querySelector('.duplication-buttons button.duplication-button--remove').style.display = 'none'; 55 | } 56 | }); 57 | } 58 | 59 | function cloneFieldset(fieldset) { 60 | if (maxRows !== null && fieldsets.length >= maxRows) { 61 | // Trigger event 62 | document.dispatchEvent(new Event('fieldset-clone-rejected')); 63 | return; 64 | } 65 | 66 | var clone = fieldset.cloneNode(true); 67 | 68 | duplicateIndex++; 69 | 70 | var nameMap = {}; 71 | 72 | // Process input fields 73 | clone.querySelectorAll('input[name], select[name], textarea[name]').forEach(function(input) { 74 | input.classList.remove('error'); 75 | 76 | var oldId = input.id; 77 | if (typeof oldId !== 'undefined') { 78 | var isDuplicate = oldId.indexOf('_duplicate_'); 79 | if (isDuplicate >= 0) { 80 | oldId = oldId.substr(0, isDuplicate); 81 | } 82 | var newId = oldId + '_duplicate_' + duplicateIndex; 83 | 84 | // Search for the widget parent 85 | var closestFieldset = input.closest(options.widgetSelector); 86 | 87 | // Search for all labels within the widget 88 | var labels = closestFieldset.querySelectorAll('label[for="'+ input.id +'"]'); 89 | 90 | // Iterate over each label 91 | labels.forEach(function(label) { 92 | // Set the `for` attribute to the new ID 93 | label.setAttribute('for', newId); 94 | 95 | // Check if the label has an ID attribute and update it 96 | if (typeof label.id !== 'undefined') { 97 | label.id = label.id + '_duplicate_' + duplicateIndex; 98 | } 99 | }); 100 | 101 | input.id = newId; 102 | } 103 | 104 | var oldName = input.name; 105 | if (typeof oldName !== 'undefined') { 106 | var isArray = oldName.endsWith('[]'); 107 | if (isArray) { 108 | oldName = oldName.substring(0, oldName.length - 2); 109 | } 110 | var isDuplicate = oldName.indexOf('_duplicate_'); 111 | if (isDuplicate >= 0) { 112 | oldName = oldName.substr(0, isDuplicate); 113 | } 114 | var newName = oldName + '_duplicate_' + duplicateIndex; 115 | if (isArray) { 116 | newName += '[]'; 117 | } 118 | input.name = newName; 119 | 120 | nameMap[oldName] = newName; 121 | } 122 | 123 | var value = input.getAttribute('value'); 124 | 125 | if (input.type !== 'checkbox' && input.type !== 'radio') { 126 | if (input.value && fieldset.classList.contains('duplicate-fieldset-donotcopy')) { 127 | input.value = value; 128 | } 129 | } else { 130 | input.checked = false; 131 | } 132 | }); 133 | 134 | // Process cff fieldsets 135 | clone.querySelectorAll('fieldset[data-cff-condition]').forEach(function(e) { 136 | let condition = e.dataset.cffCondition; 137 | 138 | for (const [key, value] of Object.entries(nameMap)) { 139 | condition = condition.replaceAll(key, value); 140 | } 141 | 142 | e.dataset.cffCondition = condition; 143 | }); 144 | 145 | // Remove some other stuff 146 | clone.querySelectorAll('label').forEach(function(label) { 147 | label.classList.remove('error'); 148 | }); 149 | clone.querySelectorAll('p.error').forEach(function(p) { 150 | p.remove(); 151 | }); 152 | clone.querySelectorAll(options.widgetSelector).forEach(function(widget) { 153 | widget.classList.remove('error'); 154 | }); 155 | 156 | // Set as duplicate 157 | clone.classList.add('duplicate'); 158 | 159 | // Assign the button actions 160 | buttonActions(clone); 161 | 162 | // Insert after fieldset 163 | fieldset.after(clone); 164 | 165 | // Update the fieldset list 166 | updateFieldsets(); 167 | 168 | // Disable the 'add' button if no additional row is allowed 169 | if (maxRows !== null && fieldsets.length >= maxRows) { 170 | fieldsets.forEach(function(fieldset) { 171 | fieldset.querySelector('.duplication-button--add').classList.add('disabled'); 172 | fieldset.querySelector('.duplication-button--add').setAttribute('disabled', 'disabled'); 173 | }); 174 | } 175 | 176 | // Trigger event 177 | document.dispatchEvent(new Event('fieldset-cloned')); 178 | } 179 | 180 | function removeFieldset(fieldset) { 181 | if (fieldsets.length > 1 && fieldset.classList.contains('duplicate')) { 182 | fieldset.remove(); 183 | updateFieldsets(); 184 | } 185 | } 186 | 187 | function buttonActions(fieldset) { 188 | fieldset.querySelector('.duplication-buttons button.duplication-button--add').addEventListener('click', function(e) { 189 | e.preventDefault(); 190 | cloneFieldset(fieldset); 191 | return false; 192 | }); 193 | 194 | if (fieldset.classList.contains('duplicate')) { 195 | fieldset.querySelector('.duplication-buttons button.duplication-button--remove').addEventListener('click', function(e) { 196 | e.preventDefault(); 197 | removeFieldset(fieldset); 198 | return false; 199 | }); 200 | } 201 | } 202 | 203 | function addButtons(fieldset) { 204 | var buttonsContainer = fieldset.querySelector('.duplication-buttons'); 205 | if (buttonsContainer) { 206 | buttonsContainer.remove(); 207 | } 208 | 209 | // Generate the button container 210 | var buttonsContainer = document.createElement('div'); 211 | buttonsContainer.classList.add('duplication-buttons'); 212 | 213 | // Generate the add button 214 | var addButton = document.createElement('button'); 215 | addButton.type = 'button'; 216 | addButton.classList.add('duplication-button', 'duplication-button--add'); 217 | addButton.textContent = options.buttonAdd; 218 | buttonsContainer.appendChild(addButton); 219 | 220 | // Generate the remove button 221 | var removeButton = document.createElement('button'); 222 | removeButton.type = 'button'; 223 | removeButton.classList.add('duplication-button', 'duplication-button--remove'); 224 | removeButton.textContent = options.buttonRemove; 225 | buttonsContainer.appendChild(removeButton); 226 | 227 | // Append or prepend the buttons 228 | if (options.prepend) { 229 | fieldset.insertBefore(buttonsContainer, fieldset.firstChild); 230 | } else { 231 | fieldset.appendChild(buttonsContainer); 232 | } 233 | 234 | // Set the button actions 235 | buttonActions(fieldset); 236 | } 237 | 238 | // Update the fieldset list 239 | updateFieldsets(); 240 | } 241 | 242 | window.fieldsetDuplication = fieldsetDuplication; 243 | })(); 244 | -------------------------------------------------------------------------------- /src/Resources/public/jquery.fieldset.duplication.js: -------------------------------------------------------------------------------- 1 | (function($) 2 | { 3 | "use strict"; 4 | 5 | $.fn.fieldsetDuplication = function(settings) 6 | { 7 | var defaults = { 8 | prepend: false, 9 | buttonAdd: '+', 10 | buttonRemove: '×', 11 | widgetSelector: '.widget', 12 | }; 13 | 14 | $(this).each(function(i, e) 15 | { 16 | var $original = $(this); 17 | var $fieldsets = null; 18 | var selector = null; 19 | var maxRows = null; 20 | var duplicateIndex = 0; 21 | var options = $.extend({}, defaults, settings, $original.data('fieldset-duplication-config') || {}) 22 | 23 | // determine the fieldset group selector 24 | var classList = $original.attr('class').split(/\s+/); 25 | $.each(classList, function(index, item) 26 | { 27 | if (item.match(/duplicate-fieldset-/)) 28 | { 29 | selector = '.' + item; 30 | return false; 31 | } 32 | }); 33 | 34 | // determine the max rows configuration 35 | $.each(classList, function(index, item) 36 | { 37 | if (item.match(/duplicate-fieldset-maxRows-/)) 38 | { 39 | maxRows = item.substring("duplicate-fieldset-maxRows-".length); 40 | return false; 41 | } 42 | }); 43 | 44 | // determine the current duplicate index 45 | const lastDuplicateField = Array.from(document.querySelectorAll(selector+' [name*="_duplicate_"]')).pop(); 46 | 47 | if (lastDuplicateField) { 48 | duplicateIndex = parseInt(lastDuplicateField.getAttribute('name').slice(-1), 10); 49 | } 50 | 51 | var updateFieldsets = function() 52 | { 53 | $fieldsets = $(selector); 54 | $fieldsets.each(function(i, e) 55 | { 56 | var $fieldset = $(this); 57 | $fieldset.removeClass('last'); 58 | $fieldset.removeClass('first'); 59 | if (i == 0) $fieldset.addClass('first'); 60 | if (i == $fieldsets.length - 1) $fieldset.addClass('last'); 61 | addButtons($fieldset); 62 | if ($fieldset.hasClass('duplicate')) 63 | $fieldset.find('.duplication-buttons button.duplication-button--remove').show(); 64 | else 65 | $fieldset.find('.duplication-buttons button.duplication-button--remove').hide(); 66 | }); 67 | }; 68 | 69 | var cloneFieldset = function($fieldset) 70 | { 71 | if (maxRows != null && $fieldsets.length >= maxRows) 72 | { 73 | // trigger event 74 | $(document).trigger('fieldset-clone-rejected', [$fieldset, $fieldsets, maxRows]); 75 | return; 76 | } 77 | 78 | // clone the fieldset 79 | var $clone = $fieldset.clone(); 80 | 81 | duplicateIndex++; 82 | 83 | const nameMap = {}; 84 | 85 | // process input fields 86 | $clone.find('input[name], select[name], textarea[name]').each(function() { 87 | var $input = $(this); 88 | $input.removeClass('error'); 89 | 90 | var oldId = $input.attr('id'); 91 | if (typeof oldId !== 'undefined') { 92 | var isDuplicate = oldId.indexOf('_duplicate_'); 93 | if (isDuplicate >= 0) { 94 | oldId = oldId.substr(0, isDuplicate); 95 | } 96 | var newId = oldId + '_duplicate_' + duplicateIndex; 97 | 98 | $input.closest(options.widgetSelector).find('label[for="'+$input.attr('id')+'"]').each(function() { 99 | var $label = $(this); 100 | $label.attr('for', newId); 101 | 102 | if (typeof $label.attr('id') !== 'undefined') { 103 | $label.attr('id', $label.attr('id') + '_duplicate_' + duplicateIndex); 104 | } 105 | }); 106 | 107 | $input.attr('id', newId); 108 | } 109 | 110 | var oldName = $input.attr('name'); 111 | if (typeof oldName !== 'undefined') { 112 | var isArray = oldName.endsWith('[]'); 113 | if (isArray) { 114 | oldName = oldName.substring(0, oldName.length - 2); 115 | } 116 | var isDuplicate = oldName.indexOf('_duplicate_'); 117 | if (isDuplicate >= 0) { 118 | oldName = oldName.substr(0, isDuplicate); 119 | } 120 | var newName = oldName + '_duplicate_' + duplicateIndex; 121 | if (isArray) { 122 | newName += '[]'; 123 | } 124 | $input.attr('name', newName); 125 | 126 | nameMap[oldName] = newName; 127 | } 128 | 129 | var value = $input.attr('value'); 130 | 131 | if ($input.attr('type') !== 'checkbox' && $input.attr('type') !== 'radio' ) { 132 | if ($input.val() && $fieldset.hasClass('duplicate-fieldset-donotcopy')) { 133 | $input.val(value); 134 | } 135 | } else { 136 | $input.not('[checked]').prop('checked', false); 137 | } 138 | }); 139 | 140 | // process cff fieldsets 141 | $clone.find('fieldset[data-cff-condition]').each((i, e) => { 142 | let condition = e.dataset.cffCondition; 143 | 144 | for (const [key, value] of Object.entries(nameMap)) { 145 | condition = condition.replaceAll(key, value); 146 | } 147 | 148 | e.dataset.cffCondition = condition; 149 | }); 150 | 151 | // remove some other stuff 152 | $clone.find('label').removeClass('error'); 153 | $clone.find('p.error').remove(); 154 | $clone.find(options.widgetSelector).removeClass('error'); 155 | 156 | // set as duplicate 157 | $clone.addClass('duplicate'); 158 | 159 | // assign the button actions 160 | buttonActions($clone); 161 | 162 | // insert after fieldset 163 | $fieldset.after($clone); 164 | 165 | // update the fieldset list 166 | updateFieldsets(); 167 | 168 | // disable the 'add' button if no additional row is allowed 169 | if (maxRows != null && $fieldsets.length >= maxRows) 170 | { 171 | $fieldsets.each(function(i, e) 172 | { 173 | var $fieldset = $(this); 174 | $fieldset.find('.duplication-button--add').addClass('disabled'); 175 | $fieldset.find('.duplication-button--add').attr('disabled', 'disabled'); 176 | }); 177 | } 178 | 179 | // trigger event 180 | $(document).trigger('fieldset-cloned', [$clone]); 181 | }; 182 | 183 | var removeFieldset = function($fieldset) 184 | { 185 | if ($fieldsets.length > 1 && $fieldset.hasClass('duplicate')) 186 | { 187 | $fieldset.remove(); 188 | updateFieldsets(); 189 | } 190 | } 191 | 192 | var buttonActions = function($fieldset) 193 | { 194 | $fieldset.find('.duplication-buttons button.duplication-button--add').off('click').on('click', function(e) 195 | { 196 | e.preventDefault(); 197 | cloneFieldset($(this).closest('fieldset')); 198 | return false; 199 | }); 200 | 201 | if ($fieldset.hasClass('duplicate')) 202 | { 203 | $fieldset.find('.duplication-buttons button.duplication-button--remove').off('click').on('click', function(e) 204 | { 205 | e.preventDefault(); 206 | removeFieldset($(this).closest('fieldset')); 207 | return false; 208 | }); 209 | } 210 | }; 211 | 212 | var addButtons = function($fieldset) 213 | { 214 | $fieldset.find('.duplication-buttons').remove(); 215 | 216 | // generate the button container 217 | var $buttons = $('
'); 218 | 219 | // generate the add button 220 | var $add = $(''); 221 | $buttons.append($add); 222 | 223 | // generate the remove button 224 | var $remove = $(''); 225 | $buttons.append($remove); 226 | 227 | // append or prepend the buttons 228 | if (options.prepend) 229 | { 230 | $fieldset.prepend($buttons); 231 | } 232 | else 233 | { 234 | $fieldset.append($buttons); 235 | } 236 | 237 | // set the button actions 238 | buttonActions($fieldset); 239 | } 240 | 241 | // update the fieldset list 242 | updateFieldsets(); 243 | }); 244 | 245 | return this; 246 | }; 247 | 248 | })(jQuery); 249 | -------------------------------------------------------------------------------- /src/EventListener/FormHookListener.php: -------------------------------------------------------------------------------- 1 | requestStack = $requestStack; 35 | $this->fieldHelper = $fieldHelper; 36 | } 37 | 38 | public function onLoadFormField(Widget $widget, string $formId, array $data, Form $form): Widget 39 | { 40 | if ($this->fieldHelper->isFieldsetStart($widget) && $widget->allowDuplication && !str_contains($widget->name, '_duplicate_')) { 41 | $arrClasses = !empty($widget->class) ? explode(' ', $widget->class) : []; 42 | $arrClasses[] = 'allow-duplication'; 43 | $arrClasses[] = 'duplicate-fieldset-'.$widget->id; 44 | 45 | if (!empty($widget->maxDuplicationRows)) { 46 | $arrClasses[] = 'duplicate-fieldset-maxRows-'.$widget->maxDuplicationRows; 47 | } 48 | 49 | if (!empty($widget->doNotCopyExistingValues)) { 50 | $arrClasses[] = 'duplicate-fieldset-donotcopy'; 51 | } 52 | 53 | $widget->class = implode(' ', $arrClasses); 54 | } 55 | 56 | return $widget; 57 | } 58 | 59 | public function onCompileFormFields(array $fields, $formId, Form $objForm): array 60 | { 61 | static $alreadyProcessed = false; 62 | 63 | // Ensure the listener is called only once (e.g. in combination with MPForms) 64 | if ($alreadyProcessed) { 65 | return $fields; 66 | } 67 | 68 | $alreadyProcessed = true; 69 | $submittedData = []; 70 | 71 | // Get the submitted data from the request 72 | if (($request = $this->requestStack->getCurrentRequest()) !== null) { 73 | $submittedData = $request->request->all(); 74 | } 75 | 76 | // Get the submitted data from MPForms 77 | if (0 === \count($submittedData) && class_exists(\MPFormsFormManager::class)) { 78 | $manager = new \MPFormsFormManager($objForm->id); 79 | $submittedData = $manager->getDataOfStep($manager->getCurrentStep())['originalPostData'] ?? []; 80 | } 81 | 82 | // check if form was submitted 83 | if (($submittedData['FORM_SUBMIT'] ?? null) === $formId) { 84 | $fieldsetGroups = $this->buildFieldsetGroups($fields); 85 | 86 | $processed = []; 87 | $fieldsetDuplicates = []; 88 | 89 | // search for duplicates 90 | foreach (array_keys($submittedData) as $duplicateName) { 91 | // check if already processed 92 | if (\in_array($duplicateName, $processed, true)) { 93 | continue; 94 | } 95 | 96 | // check if it is a duplicate 97 | if (false !== ($intPos = strpos($duplicateName, '_duplicate_'))) { 98 | // get the non duplicate name 99 | $originalName = substr($duplicateName, 0, $intPos); 100 | 101 | // get the duplicate number 102 | preg_match_all('!\d+!', $duplicateName, $numberMatches); 103 | $duplicateNumber = (int) end($numberMatches[0]); 104 | 105 | // clone the fieldset 106 | foreach ($fieldsetGroups as $fieldsetGroup) { 107 | foreach ($fieldsetGroup as $field) { 108 | if ($field->name === $originalName) { 109 | // new sorting base number 110 | $sorting = $fieldsetGroup[\count($fieldsetGroup) - 1]->sorting; 111 | 112 | $duplicatedFields = []; 113 | 114 | foreach ($fieldsetGroup as $field) { 115 | // set the actual duplicate name 116 | $duplicateName = $field->name.'_duplicate_'.$duplicateNumber; 117 | 118 | // clone the field 119 | $clone = clone $field; 120 | 121 | // remove allow duplication class 122 | if ($this->fieldHelper->isFieldsetStart($clone)) { 123 | $clone->class = implode(' ', array_diff(explode(' ', $clone->class ?? ''), ['allow-duplication'])); 124 | $clone->class .= ($clone->class ? ' ' : '').'duplicate-fieldset-'.$field->id.' duplicate'; 125 | } 126 | 127 | // set the id 128 | $clone->id = $field->id.'_duplicate_'.$duplicateNumber; 129 | 130 | // set the original id 131 | $clone->originalId = $field->id; 132 | 133 | // set the name 134 | $clone->name = $duplicateName; 135 | 136 | // set the sorting 137 | $clone->sorting = ++$sorting; 138 | 139 | // add the clone 140 | $duplicatedFields[] = $clone; 141 | 142 | // add to processed 143 | $processed[] = $duplicateName; 144 | } 145 | 146 | $fieldsetDuplicates[] = $duplicatedFields; 147 | 148 | break 2; 149 | } 150 | } 151 | } 152 | } 153 | } 154 | 155 | // reverse the fieldset duplicates 156 | $fieldsetDuplicates = array_reverse($fieldsetDuplicates); 157 | 158 | // process $fields 159 | $fields = array_values($fields); 160 | 161 | // go through the duplicated fieldsets 162 | foreach ($fieldsetDuplicates as $duplicatedFieldset) { 163 | // search for the stop field 164 | $stopId = null; 165 | foreach ($duplicatedFieldset as $duplicatedField) { 166 | if ($this->fieldHelper->isFieldsetStop($duplicatedField)) { 167 | $stopId = $duplicatedField->originalId; 168 | break; 169 | } 170 | } 171 | 172 | // search for the index position of the original stop field 173 | if (null !== $stopId) { 174 | $stopIdx = null; 175 | for ($i = 0; $i < \count($fields); ++$i) { 176 | if ($fields[$i]->id === $stopId) { 177 | $stopIdx = $i; 178 | break; 179 | } 180 | } 181 | 182 | // insert fields after original stop field 183 | if (null !== $stopIdx) { 184 | array_splice($fields, $stopIdx + 1, 0, $duplicatedFieldset); 185 | } 186 | } 187 | } 188 | } 189 | 190 | // return the fields 191 | return $fields; 192 | } 193 | 194 | public function onStoreFormData(array $set, Form $form): array 195 | { 196 | $newSet = []; 197 | $duplicateFieldsData = []; 198 | 199 | foreach ($set as $name => $value) { 200 | if (str_contains($name, '_duplicate_')) { 201 | $duplicateFieldsData[$name] = $value; 202 | continue; 203 | } 204 | 205 | $newSet[$name] = $value; 206 | } 207 | 208 | if (!empty($duplicateFieldsData) && Database::getInstance()->fieldExists(self::TABLE_FIELD, $form->targetTable)) { 209 | $newSet['fieldset_duplicates'] = json_encode($duplicateFieldsData); 210 | } 211 | 212 | return $newSet; 213 | } 214 | 215 | public function onPrepareFormData(array &$submittedData, array $labels, array $fields, Form $form): void 216 | { 217 | $fieldsetGroups = $this->buildFieldsetGroups($fields); 218 | $values = $this->groupFieldsetValues($fieldsetGroups, $submittedData); 219 | 220 | // Disable debug mode so that no html comments are rendered in the templates 221 | $debugMode = Config::get('debugMode'); 222 | Config::set('debugMode', false); 223 | 224 | foreach ($values as $row) { 225 | if (!$row['config']->allowDuplication) { 226 | continue; 227 | } 228 | 229 | $templateFormats = StringUtil::deserialize($row['config']->notificationTokenTemplates, true); 230 | foreach ($templateFormats as $format) { 231 | if (!$format['format'] || !$format['template']) { 232 | continue; 233 | } 234 | 235 | $template = new FrontendTemplate($format['template']); 236 | $template->setData( 237 | [ 238 | 'labels' => $labels, 239 | 'form' => $form, 240 | 'config' => $row['config'], 241 | 'values' => $row['data'], 242 | ] 243 | ); 244 | 245 | $submittedData[$row['config']->name.'_'.$format['format']] = $template->parse(); 246 | } 247 | } 248 | 249 | Config::set('debugMode', $debugMode); 250 | } 251 | 252 | /** 253 | * @param array|Widget[]|FormFieldModel[] $fields 254 | */ 255 | private function buildFieldsetGroups(array $fields): array 256 | { 257 | // field set groups 258 | $fieldsetGroups = []; 259 | 260 | // fielset group stack 261 | $fieldsetGroupStack = []; 262 | 263 | // field set group 264 | $fieldsetGroup = []; 265 | 266 | // go through each field 267 | foreach ($fields as $field) { 268 | // check if we can process duplicates 269 | if ($this->fieldHelper->isFieldsetStart($field)) { 270 | // check if nested fieldsets are detected and ensure fields are collected 271 | if ([] !== $fieldsetGroupStack) { 272 | $fieldsetGroups[$fieldsetGroup[0]->id] = $fieldsetGroup; 273 | $fieldsetGroup = []; 274 | } 275 | 276 | $fieldsetGroup[] = $field; 277 | $fieldsetGroupStack[] = $field->id; 278 | } elseif ($this->fieldHelper->isFieldsetStop($field)) { 279 | $groupId = array_pop($fieldsetGroupStack); 280 | $fieldsetGroup[] = $field; 281 | $fieldsetGroups[$groupId] = $fieldsetGroup; 282 | 283 | // Check if nested fieldset are used and restore the out fieldset group 284 | if ($fieldsetGroupStack) { 285 | $fieldsetGroup = $fieldsetGroups[current($fieldsetGroupStack)]; 286 | } else { 287 | $fieldsetGroup = []; 288 | } 289 | } elseif (!empty($fieldsetGroup)) { 290 | $fieldsetGroup[] = $field; 291 | } 292 | } 293 | 294 | return $fieldsetGroups; 295 | } 296 | 297 | private function groupFieldsetValues(array $fieldsetGroups, array $submittedData): array 298 | { 299 | $data = []; 300 | 301 | foreach ($fieldsetGroups as $fieldsetId => $fieldsetGroup) { 302 | $row = []; 303 | $referenceGroup = $fieldsetGroup[0]->originalId > 0 304 | ? $fieldsetGroups[$fieldsetGroup[0]->originalId] 305 | : $fieldsetGroup; 306 | 307 | foreach ($fieldsetGroup as $formFieldIndex => $formFieldModel) { 308 | if (\array_key_exists($formFieldModel->name, $submittedData)) { 309 | $row[$referenceGroup[$formFieldIndex]->name] = $submittedData[$formFieldModel->name]; 310 | } 311 | } 312 | 313 | $data[$referenceGroup[0]->id]['config'] = $referenceGroup[0]; 314 | $data[$referenceGroup[0]->id]['data'][] = $row; 315 | } 316 | 317 | return $data; 318 | } 319 | } 320 | --------------------------------------------------------------------------------