phpformssymfonycollectionssymfony4

Symfony 4.4 how to start with 0 fields using a collectionType


I have a entity based collectionType in my form. I want users to be able to dynamicly add new fields based on a different field. So at the page load I want 0 fields to be shown. After the user filled in all the fields to make the generation process possible and the user clicked the "next" button the new collection fields should be added.

Currently, symfony forms always generates 1 field. But since I gave no data to it yet, it only shows a empty div. But it breaks my Javascript :(

I know I could just do a workaround for this, but I want this field to not be generated at all. No html content should be made before the users clicked the "next button".

This is my collection part of the form:

// Builder ...
->add('contracts', CollectionType::class, [
    'entry_type' => ContractsType::class,
    'entry_options' => ['label' => false],
    'allow_add' => true,
    'allow_delete' => true,
    'by_reference' => false,
])

This is the ContractsType:

->add('start_date', WeekType::class, [
    'label_attr' => [
        'class' => 'bold',
    ],
    'attr' => [
        'class' => 'input-margin',
    ],
    'input' => 'array',
    'widget' => 'choice',
])
->add('end_date', WeekType::class, [
    'label_attr' => [
        'class' => 'bold',
    ],
    'attr' => [
        'class' => 'input-margin',
    ],
    'input' => 'array',
    'widget' => 'choice',
])
->add('contract_hours', TextType::class, [
    'label_attr' => [
        'class' => 'bold',
    ],
    'attr' => [
        'class' => 'input-text input-margin',
        'placeholder' => 'Vul het aantal contract uren in',
        'min' => 0,
        'max' => 60
    ]
])

My twig:

{# Startdate, enddate and contract hours fields. Data sets are specific for each field since I need nice styling. #}
<div id="ContractFields" class="contracts"
    data-start-label="{{ form_label(userEntityForm.contracts.vars.prototype.start_date, 'Begintijd')|e('html_attr') }}" 
    data-start-date-year="{{ form_widget(userEntityForm.contracts.vars.prototype.start_date.year, { attr: { class: 'select-field mr-1' }})|e('html_attr') }}"
    data-start-date-week="{{ form_widget(userEntityForm.contracts.vars.prototype.start_date.week, { attr: { class: 'select-field' }})|e('html_attr') }}"
    data-end-label="{{ form_label(userEntityForm.contracts.vars.prototype.end_date, 'Eindtijd')|e('html_attr') }}"
    data-end-date-year="{{ form_widget(userEntityForm.contracts.vars.prototype.end_date.year, { attr: { class: 'select-field mr-1' }})|e('html_attr') }}"
    data-end-date-week="{{ form_widget(userEntityForm.contracts.vars.prototype.end_date.week, { attr: { class: 'select-field' }})|e('html_attr') }}"
    data-hours-label="{{ form_label(userEntityForm.contracts.vars.prototype.contract_hours, 'Contract uren')|e('html_attr') }}"
    data-hours="{{ form_widget(userEntityForm.contracts.vars.prototype.contract_hours, {'type' : 'number'})|e('html_attr') }}">
    
    // This if is here for testing, doesn't do much about the generating part.
    {% if userEntityForm.contracts is empty %}
        {# {{ form_widget(userEntityForm.contracts) }} #}
    {% else %}
        {% for contract in userEntityForm.contracts %}

            {# Class to make sure it is known in JS how many contract fields have been added. #}
            <div class="d-none class-to-count-amount-of-fields">

                <div class="d-flex">

                    {# Contract start date field #}
                    <div class="mr-2">
                        {{ form_label(contract.start_date, 'Begintijd') }}
                        <div class="input-margin">
                            {{- form_widget(contract.start_date.year, { attr: { class: 'select-field mr-1' }}) -}}
                            {{- form_widget(contract.start_date.week, { attr: { class: 'select-field' }}) -}}
                        </div>
                    </div>

                    {# Contract end date field. #}
                    <div>
                        {{ form_label(contract.end_date, 'Eindtijd') }}

                        <div class="input-margin">
                            {{- form_widget(contract.end_date.year, { attr: { class: 'select-field mr-1' }}) -}}
                            {{- form_widget(contract.end_date.week, { attr: { class: 'select-field' }}) -}}
                        </div>
                    </div>
                </div>

                {# Contract hours field. #}
                <div>
                    {{ form_label(contract.contract_hours, 'Contract uren') }}
                    {{ form_widget(contract.contract_hours, {'type' : 'number'}) }}
                </div>
            </div>

        {% endfor %}
    {% endif %}
</div>

My Javascript:

// Only generate the remaining contracts. We dont want more than the user asks for.
for (let i = contracts.length; i < userEntityFormAmountOfContracts.value; i++) {

    // Get the data-prototype from _form.html.twig.
    var startLabel = contractFields.dataset.startLabel;
    var startDateYear = contractFields.dataset.startDateYear;
    var startDateWeek = contractFields.dataset.startDateWeek;
    var endLabel = contractFields.dataset.endLabel;
    var endDateYear = contractFields.dataset.endDateYear;
    var endDateWeek = contractFields.dataset.endDateWeek;
    var hoursLabel = contractFields.dataset.hoursLabel;
    var contractHours = contractFields.dataset.hours;

    // get the current index.
    var index = contractFields.dataset.index;

    // Replace '__name__' in the prototype's HTML to instead be a number based on how many items we have.
    startLabel = startLabel.replace(/__name__/g, index);
    startDateYear = startDateYear.replace(/__name__/g, index);
    startDateWeek = startDateWeek.replace(/__name__/g, index);
    endLabel = endLabel.replace(/__name__/g, index);
    endDateYear = endDateYear.replace(/__name__/g, index);
    endDateWeek = endDateWeek.replace(/__name__/g, index);
    hoursLabel = hoursLabel.replace(/__name__/g, index);
    contractHours = contractHours.replace(/__name__/g, index);

    // Create extra elements.
    var contractsContainer = document.createElement('div');
    contractsContainer.classList.add("d-none", "class-to-count-amount-of-fields");

    var datesContainer = document.createElement('div');
    datesContainer.classList.add("d-flex");

    // Start date container. (label + input).
    var startDateContainer = document.createElement('div');
    startDateContainer.classList.add("mr-2");

    // Start date container. (label + input).
    var startDateInputContainer = document.createElement('div');
    startDateInputContainer.classList.add("input-margin");

    // End date container. (label + input).
    var endDateContainer = document.createElement('div');

    // Start date container. (label + input).
    var endDateInputContainer = document.createElement('div');
    endDateInputContainer.classList.add("input-margin");

    // Contract hours container. (label + input).
    var contractHoursContainer = document.createElement('div');

    // Add labels and field container to time container.
    startDateContainer.insertAdjacentHTML('beforeend', startLabel);
    endDateContainer.insertAdjacentHTML('beforeend', endLabel);
    contractHoursContainer.insertAdjacentHTML('beforeend', hoursLabel);

    // Add date input fields. (from prototype). insertAdjacentHTML means that html in string format will be transformed.
    startDateInputContainer.insertAdjacentHTML('beforeend', startDateYear);
    startDateInputContainer.insertAdjacentHTML('beforeend', startDateWeek);
    endDateInputContainer.insertAdjacentHTML('beforeend', endDateYear);
    endDateInputContainer.insertAdjacentHTML('beforeend', endDateWeek);
    contractHoursContainer.insertAdjacentHTML('beforeend', contractHours);

    startDateContainer.appendChild(startDateInputContainer);
    endDateContainer.appendChild(endDateInputContainer);

    // Add time containers to clocking times container.
    datesContainer.appendChild(startDateContainer);
    datesContainer.appendChild(endDateContainer);

    // Adding the input field parts.
    contractsContainer.appendChild(datesContainer);
    contractsContainer.appendChild(contractHoursContainer);

    // Append all, sticking together, elements to the container with all other sticking elements (contractsContainers).
    contractFields.appendChild(contractsContainer);

    // Set default values.
    const currentDate = new Date();
    startDateInputContainer.children[0].value = 2021;
    startDateInputContainer.children[1].value = 1;
    endDateInputContainer.children[0].value = 2021;
    endDateInputContainer.children[1].value = isISOLeapYear(currentDate.getFullYear()) ? 53 : 52;

    // Increase the index with one for the next item.
    contractFields.dataset.index = parseInt(index) + 1;
}

Solution

  • If you're seeing a label for the entire collection (which should probably read "Contracts" in your case) you can remove that by adding 'label' => false to the parent form, like this:

    // Builder ...
    ->add('contracts', CollectionType::class, [
        'label' => false,
        ...
    ])
    

    When calling form_end(form) Symfony checks to see if there are any fields that you forgot to render manually. It does this by calling the isRendered method on each field (FormView) and then adds the missing fields to the end of the form. For a missing collection this adds (depending on form theme) a <div> with a <label> and a container <div> that holds the child-forms (if any).

    The isRendered method returns true if the collection as a whole (form_row(collection) or form_widget(collection)) OR all of its child-forms (form_row(child) or form_widget(child)) were rendered. In the special case where you're rendering the child-forms individually using a loop, but there are no child-forms yet, isRendered returns false and the extra fields are added to the end of the form.

    The solution above only removes the <label> from the form.

    A better solution might be to add {'render_rest': false} to the form_end(form) tag to disable the rendering of all additional fields: {{ form_end(form, {'render_rest': false}) }}

    Edit: When calling form_end(form) Symfony also renders the crsf field, so when using {'render_rest': false}, you need to render this manually using {{ form_widget(form._token) }}.