I am struggling with Symfony2 and Knpmenu to build a menu that handles:
breadcrumbs
routing with dynamic parameters
rendering of separate menus starting with different children
My Menu/Builder.php file looks like this (the extra bits like navbar, pull-nav etc are part of the mopa_bootstrap extension that handles the rendering using bootstrap classes):
namespace My\AppBundle\Menu;
use Knp\Menu\FactoryInterface;
class Builder
{
public function mainMenu(FactoryInterface $factory, array $options)
{
$menu = $factory->createItem(
'root', array(
'navbar' => true,
'pull-right' => true,
)
);
// Main Menu -> Config
// no link here, it's just a placeholder
$dropdown = $menu->addChild(
'Config', array(
'dropdown' => true,
'caret' => true,
)
);
// Menu -> Config -> User
$dropdown2 = $dropdown ->addChild(
'User', array(
'route' => 'user',
)
);
// Secondary Menu -> Edit (but child of Menu -> Config -> User)
$dropdown2->addChild(
'Edit',
array(
'route' => 'user_edit',
'routeParameters' => array('name' => $options['id']),
)
);
The idea is to have a main menu that prints the first two levels only, and a separate menu that gets rendered somewhere else to allow users moving between the edit/delete/whatever views of that specific element being viewed.
What I am trying to achieve, is to have a single structure thus to handle the parenting structure, not only to have sign parents as active in the menu, but also to be able to handle a working breadcrumb structure.
InResources/views/base.html.twig I am calling the main menu like this:
{{ mopa_bootstrap_menu('MyAppBundle:Builder:mainMenu', {depth: 2}) }}
And ideally the sub menu like this:
{% set id = app.request.attributes.get('id') %}
{% if app.request.attributes.get('_route') starts with 'user_' %}
{% set menu = knp_menu_get('MyAppBundle:Builder:mainMenu', ['User'], {'id': id }) %}
{{ knp_menu_render(menu) }}
{% endif %}
However:
knpmenu returns error when rendering the main menu, as $options['id'] isn't defined
I still can't render the secondary menu (therefore by passing the parameter 'User') - the page just returns a black output in that block
Is this approach correct?
I'm using "knplabs/knp-menu": "2.0.*#dev" and "symfony/symfony": "2.5.*"
As it turns out, using this method allowed to access effectively $request and add the conditional in the MenuBuilder.php file to prompt the sub-sub-items only under certain conditions.
The final code looks like this:
// Menu/MenuBuilder.php
namespace My\AppBundle\Menu;
use Knp\Menu\FactoryInterface;
use Symfony\Component\HttpFoundation\Request;
class MenuBuilder
{
private $factory;
/**
* #param FactoryInterface $factory
*/
public function __construct(FactoryInterface $factory)
{
$this->factory = $factory;
}
public function createMainMenu(Request $request)
{
$menu = $this->factory->createItem(
'root', array(
'navbar' => true,
'pull-right' => true,
)
);
// Main Menu -> Config
// no link here, it's just a placeholder
$dropdown = $menu->addChild(
'Config', array(
'dropdown' => true,
'caret' => true,
)
);
// Menu -> Config -> User
$dropdown2 = $dropdown ->addChild(
'User', array(
'route' => 'user',
)
);
// Secondary Menu -> Edit (but child of Menu -> Config -> User)
// Skip this part if we are not in the relevant page, thus to avoid errors
if (false !== strpos($request->get('_route'), 'user_') && $request->get('id')):
$dropdown2->addChild(
'Edit',
array(
'route' => 'user_edit',
'routeParameters' => array('id' => $request->get('id')),
)
);
endif;
And the services configuration file:
// Resources/config/services.yml
services:
my_app.menu_builder:
class: My\AppBundle\Menu\MenuBuilder
arguments: ["#knp_menu.factory"]
my_app.menu.main:
class: Knp\Menu\MenuItem
factory_service: my_app.menu_builder
factory_method: createMainMenu
arguments: ["#request"]
scope: request
tags:
- { name: knp_menu.menu, alias: main }
And then the Resources/views/base.html.twig template:
// Main menu
{% set menu = knp_menu_get('main', ['Config', 'Users'], {'id': id }) %}
{{ knp_menu_render(menu) }}
// Secondary menu
{% if app.request.attributes.get('_route') starts with 'user_' %}
{% set menu = knp_menu_get('main', ['Config', 'Users'], {'id': id }) %}
{{ mopa_bootstrap_menu(menu, {'automenu': 'navbar'}) }}
{% endif %}
The solution seems to work, but if you have a better approach please do let me know!
Related
So I am getting my head around how Timber works, I'm getting the hang of that so now I want to now get my head around ACF and Twig.
Ive added the following to functions.php:
add_action( 'acf/init', 'my_acf_init' );
function my_acf_init() {
// Bail out if function doesn’t exist.
if ( ! function_exists( 'acf_register_block' ) ) {
return;
}
// Register a new block.
acf_register_block( array(
'name' => 'example_block',
'title' => __( 'Example Block', 'your-text-domain' ),
'description' => __( 'A custom example block.', 'your-text-domain' ),
'render_callback' => 'my_acf_block_render_callback',
'category' => 'formatting',
'icon' => 'admin-comments',
'keywords' => array( 'example' ),
) );
}
/**
* This is the callback that displays the block.
*
* #param array $block The block settings and attributes.
* #param string $content The block content (emtpy string).
* #param bool $is_preview True during AJAX preview.
*/
function my_acf_block_render_callback( $block, $content = '', $is_preview = false ) {
$context = Timber::context();
// Store block values.
$context['block'] = $block;
// Store field values.
$context['fields'] = get_fields();
// Store $is_preview value.
$context['is_preview'] = $is_preview;
// Render the block.
Timber::render( 'block/example-block.twig', $context );
}
This is the block file:
{#
/**
* Block Name: Example block
*
* This is the template that displays the example block.
*/
#}
{% if is_preview %}
<p>I will only appear in the editor.</p>
{% endif %}
<div id="example-{{ block.id }}" class="wrapper">
<h1>{{ fields.title }}</h1>
<p>{{ fields.description }}</p>
</div>
<style type="text/css">
#testimonial-{{ block.id }} {
background: {{ fields.background_color }};
color: {{ fields.text_color }};
}
</style>
Where am I going wrong? I have searched loads of documentation but I haven’t found anything and I'm starting to pull my hair out over it as it should be easy
OK so this probably isn’t the best or maybe even correct way but I have finally got the ACF blocks to show on the frontend - yay!
I added the following to my fuctions.php:
add_filter('timber_context', 'bt_timber_add_to_context');
function bt_timber_add_to_context ($context) {
// add current page content
$context['page'] = Timber::get_post();
return $context;
}
Whilst I would love to say I came up with the solution myself all credit goes to #Buttered_Toast (found a solution
I try to follow this link to install and create a form with CraueFormFlowBundle
CraueFormFlowBundle tutorial
i create the file
/src/AppBundle/Form/InterventoFlow.php
namespace AppBundle/Form;
use Craue\FormFlowBundle\Form\FormFlow;
use Craue\FormFlowBundle\Form\FormFlowInterface;
class InterventoFlow extends FormFlow {
protected function loadStepsConfig() {
return array(
array(
'label' => 'wheels',
'form_type' => 'AppBundle\Form\InterventoForm',
),
array(
'label' => 'engine',
'form_type' => 'AppBundle\Form\InterventoForm',
'skip' => function($estimatedCurrentStepNumber, FormFlowInterface $flow) {
return $estimatedCurrentStepNumber > 1 && !$flow->getFormData()->canHaveEngine();
},
),
array(
'label' => 'confirmation',
),
);
}
}
after i create
/src/AppBundle/Form/InterventoForm.php
<?php
/*
* To change this license header, choose License Headers in Project Properties.
* To change this template file, choose Tools | Templates
* and open the template in the editor.
*/
namespace AppBundle/Form;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\Extension\Core\Type\TextType;
class InterventoForm extends AbstractType {
public function buildForm(FormBuilderInterface $builder, array $options) {
switch ($options['flow_step']) {
case 1:
$validValues = array(2, 4);
$builder->add('numberOfWheels', 'Symfony\Component\Form\Extension\Core\Type\ChoiceType', array(
'choices' => array_combine($validValues, $validValues),
'placeholder' => '',
));
break;
case 2:
// This form type is not defined in the example.
$builder->add('engine', 'TextType', array(
'placeholder' => 'prova',
));
break;
}
}
public function getBlockPrefix() {
return 'createIntervento';
}
}
after i create the service
services:
app.form.InterventoFlow:
class: AppBundle\Form\InterventoFlow
parent: craue.form.flow
and the controller
public function interventoAction(Request $request)
{
$formData = new Validate(); // Your form data class. Has to be an object, won't work properly with an array.
$flow = $this->get('AppBundle.form.InterventoFlow'); // must match the flow's service id
$flow->bind($formData);
// form of the current step
$form = $flow->createForm();
if ($flow->isValid($form)) {
$flow->saveCurrentStepData($form);
if ($flow->nextStep()) {
// form for the next step
$form = $flow->createForm();
} else {
// flow finished
$em = $this->getDoctrine()->getManager();
$em->persist($formData);
$em->flush();
$flow->reset(); // remove step data from the session
return $this->redirect($this->generateUrl('home')); // redirect when done
}
}
return $this->render('interventi/intervento.html.twig', array(
'form' => $form->createView(),
'flow' => $flow,
));
}
and my twig file
{% extends 'base.html.twig' %}
{% block body %}
<div>
Steps:
{% include '#CraueFormFlow/FormFlow/stepList.html.twig' %}
</div>
{{ form_start(form) }}
{{ form_errors(form) }}
{% if flow.getCurrentStepNumber() == 1 %}
<div>
When selecting four wheels you have to choose the engine in the next step.<br />
{{ form_row(form.numberOfWheels) }}
</div>
{% endif %}
{{ form_rest(form) }}
{% include '#CraueFormFlow/FormFlow/buttons.html.twig' %}
{{ form_end(form) }}
{% endblock %}
{% stylesheets '#CraueFormFlowBundle/Resources/assets/css/buttons.css' %}
<link type="text/css" rel="stylesheet" href="{{ asset_url }}" />
{% endstylesheets %}
always give me error
Attribute "autowire" on service "app.form.intervento" cannot be inherited from "_defaults" when a "parent" is set. Move your child definitions to a separate file or define this attribute explicitly in C:\xampp\htdocs\Myprject\app/config\services.yml (which is being imported from "C:\xampp\htdocs\Myprject\app/config\config.yml").
and if i comment
parent: craue.form.flow
You have requested a non-existent service "app.form.interventoFlow".
Try to dalete _defaults section from your service.yml file.
I want to do this:
$builder->add('participants', EntityType::class, array(
'label' => 'Teilnehmer',
'class' => SchoolUser::class,
'multiple' => true,
'choices' => $this->getParticipantsOfEntry($builder),
'empty_value' => 'All',
'empty_data' => null,
'preferred_choices' => array(null)
));
But I get no selected 'All' - field at all. This should not be hard, I wonder where is my mistake?
'placeholder' = 'All',
did also not work for me.
How can I do this?
I know the question was 4-months ago but I ran into a similar problem so thought I would share my solution in case it helps others.
First in the Form object:
class MealFormType extends AbstractType {
public function buildForm( FormBuilderInterface $builder, array $options ) {
$builder->add( 'courses', EntityType::class, array(
'class' => MealCourse::class,
'multiple' => true,
) );
$builder->addEventListener( FormEvents::PRE_SUBMIT, function( FormEvent $event ) {
// Remove added entries here... maybe something like
$data = $event->getData();
if( ($key = array_search( 'all', $data['courses'] ) ) !== false ) {
unset( $data['courses'][$key] );
$event->setData( $data );
}
} );
}
public function configureOptions( OptionsResolver $resolver ) {
$resolver->setDefaults( ['data_class' => Meal::class] );
}
public function finishView( FormView $view, FormInterface $form, array $options) {
$newChoice = new ChoiceView( null, 'all', 'I want them all!' );
array_unshift( $view->children['courses']->vars['choices'], $newChoice );
}
}
This creates the form with multiple meal courses that can be selected, such as appetizer, soup, salad, entree, coffee, desert, ... using the buildForm() method. Also in this method an event listener for the PRE_SUBMIT event is added whose job it will be to remove any choices we have added since they are probably not real (if they were, we wouldn't have to add them in this painful way).
It sets the data_class as usual in configureOptions().
Lastly it provides the finishView() method that creates a new choice with value of 'all' that will display as 'I want them all' in the select, then adds it at the beginning for the 'choices' array for the 'courses' form entry (this would be $view->children['courses']->vars['choices'][] = $newChoice; to put it at the end).
Ok, this is fine but it really doesn't do anything more than having an additional option to click or unclick. To use this we need some Javascript to manage the form. I just stuck this Javascript in the bottom of my Twig file and use jQuery.
The Twig file:
{% block body %}
{{ form_start(mealForm) }}
{{ form_row(mealForm.courses,
{'attr': {'class': 'js-meal_course-select'}}) }}
<button type="submit">Save</button>
{{ form_end(mealForm) }}
{% endblock %}
{% block javascripts %}
{{ parent() }}
<script>
var mealCourseAllSelected = false;
jQuery(document).ready( function() {
// This will be called whenever an entry is selected or deselected
$('.js-meal_course-select').change( function() {
var selectedSet = $(this).val();
if( mealCourseAllSelected &&
(selectedSet.length != $(this).get(0).options.length - 1 ||
selectedSet.includes( 'all' )) ) {
var opts = $(this).get(0).options;
var len = opts.length;
for( var i = 0; i < len; ++i ) {
if( opts[i].value == 'all' ) opts[i].selected = false;
}
mealCourseAllSelected = false;
} else if( selectedSet.includes( "all" ) ||
selectedSet.length == $(this).get(0).options.length - 1 ) {
$('.js-meal_course-select option').prop( 'selected', true );
mealCourseAllSelected = true;
}
}
);
</script>
{% endblock %}
The JavaScript responds to changes to what is selected. If the select option with the value of 'all' is in the selected set, then all the select options are marked as selected, and a flag is set to say so. This is also done if everything is selected except the 'all' entry because that is kind of the definition of all.
If the 'all are selected' flag is set and there is a change, then that change must be deselecting at least one, unless that one is the select option with the value of 'all', it deselects the selection option with the value of 'all' and clears the 'all are selected' flag. If all are selected and the user tries to unselect just the select option with the value of 'all', it is just reselected.
In my application, I created a form using the collection field type :
$builder->add('tags', 'collection', array(
'type' => new TagType(),
'label' => false,
'allow_add' => true,
'allow_delete' => true,
'by_reference' => false
));
With some JQuery, this code works correctly, but now I would like to select one of this dynamic tag to make it "the main tag".
In my Tag entity, I added a boolean attribute which define if the tag is the main or not :
/**
* #ORM\Column(name="main", type="boolean")
*/
private $main;
But in my view, each row now contains a checkbox. So I can select more than one main tag. How to transform this checkbox in radio button please ?
You're not tackling the problem from the right angle. If there should be a main tag, then this property should not be added in the Tag entity itself, but in the entity that contains it!
I'm speaking of the data_class entity related to the form having the tags attribute. This is the entity that should have a mainTag property.
If defined properly, this new mainTag attribute will not be a boolean, for it will contain a Tag instance, and thus will not be associated to a checkbox entry.
So, the way I see it, you should have a mainTag property containing your instance and a tags property that conatins all other tags.
The problem with that is that your collection field will no longer contain the main tag. You should thus also create a special getter getAllTags that will merge your main tag with all others, and change your collection definition to:
$builder->add('allTags', 'collection', array(
'type' => new TagType(),
'label' => false,
'allow_add' => true,
'allow_delete' => true,
'by_reference' => false
));
Now, how do we add the radio boxes, you may ask? For this, you will have to generate a new field:
$builder->add('mainTag', 'radio', array(
'type' => 'choice',
'multiple' => false,
'expanded' => true,
'property_path' => 'mainTag.id', // Necessary, for 'choice' does not support data_classes
));
These are the basics however, it only grows more complex from here. The real problem here is how your form is displayed. In a same field, you mix the usual display of a collection and the display of a choice field of the parent form of that collection. This will force you to use form theming.
To allow some room to reusability, you need to create a custom field. The associated data_class:
class TagSelection
{
private mainTag;
private $tags;
public function getAllTags()
{
return array_merge(array($this->getMainTag()), $this->getTags());
}
public function setAllTags($tags)
{
// If the main tag is not null, search and remove it before calling setTags($tags)
}
// Getters, setters
}
The form type:
class TagSelectionType extends AbstractType
{
protected buildForm( ... )
{
$builder->add('allTags', 'collection', array(
'type' => new TagType(),
'label' => false,
'allow_add' => true,
'allow_delete' => true,
'by_reference' => false
));
// Since we cannot know which tags are available before binding or setting data, a listener must be used
$formFactory = $builder->getFormFactory();
$listener = function(FormEvent $event) use ($formFactory) {
$data = $event->getForm()->getData();
// Get all tags id currently in the data
$choices = ...;
// Careful, in PRE_BIND this is an array of scalars while in PRE_SET_DATA it is an array of Tag instances
$field = $this->factory->createNamed('mainTag', 'radio', null, array(
'type' => 'choice',
'multiple' => false,
'expanded' => true,
'choices' => $choices,
'property_path' => 'mainTag.id',
));
$event->getForm()->add($field);
}
$builder->addEventListener(FormEvent::PRE_SET_DATA, $listener);
$builder->addEventListener(FormEvent::PRE_BIND, $listener);
}
public function getName()
{
return 'tag_selection';
}
public function setDefaultOptions(OptionsResolverInterface $resolver)
{
$resolver->setDefaults(array(
'data_class' => 'TagSelection', // Adapt depending on class name
// 'prototype' => true,
));
}
}
Finally, in the form theme template:
{% block tag_selection_widget %}
{% spaceless %}
{# {% set attr = attr|default({})|merge({'data-prototype': form_widget(prototype)}) %} #}
<ul {{ block('widget_attributes') }}>
{% for child in form.allTags %}
<li>{{ form_widget(form.mainTag[child.name]) }} {{ form_widget(child) }}</li>
{% endfor %}
</ul>
{% endspaceless %}
{% endblock tag_selection_widget %}
Lastly, we need to include that in your parent entity, the one that originally contained tags:
class entity
{
// Doctrine definition and whatnot
private $tags;
// Doctrine definition and whatnot
private $mainTag;
...
public setAllTags($tagSelection)
{
$this->setMainTag($tagSelection->getMainTag());
$this->setTags($tagSelection->getTags());
}
public getAllTags()
{
$ret = new TagSelection();
$ret->setMainTag($this->getMainTag());
$ret->setTags($this->getTags());
return $ret;
}
...
}
And in your original form:
$builder->add('allTags', new TagSelection(), array(
'label' => false,
));
I recognize the solution I propose is verbose, however it seems to me to be the most efficient. What you are trying to do cannot be done easily in Symfony.
You can also note that there is an odd "prototype" option in the comment. I just wanted to underline a very useful property of "collection" in your case: the prototype option contains a blank item of your collection, with placeholders to replace. This allow to quickly add new items in a collection field using javascript, more info here.
This is not the right solution, but since you are using jQuery to add/remove...
TagType
->add('main', 'radio', [
'attr' => [
'class' => 'toggle'
],
'required' => false
])
jQuery
div.on('change', 'input.toggle', function() {
div
.find('input.toggle')
.not(this)
.removeAttr('checked');
});
http://jsfiddle.net/coma/CnvMk/
And use a callback constraint to ensure that there is only one main tag.
First thing you should wary about - its that in your scheme if tag become main for one entity it will be main for all entities because the tag store attribute and few entities can be tagged with one tag.
So simplest decision here is to create new property main_tag near tags in your entity, create hidden field main_tag(with id to Tag Data transformer) in your form and populate and change this field with jQuery(for example set it on tag click or clear on main tag delete)
Maybe there is something to do with the multiple form option, but it might require a little tweaking on your collection form and tag entity.
Let's say I have a routing setup like that :
users_list:
pattern: /users
defaults: { _controller: AcmeBundle:User:list }
user_edit:
pattern: /user-edit/{id}
defaults: { _controller: AcmeBundle:User:edit }
I would like to setup a menu like this
Home
--Users list
----User edition
But I'm not sure how to handle my route 'user_edit' with dynamic params "id". Actually I don't want to display the link (which id ?), but I would like the 'Users list' parent node to be active if I edit an user.
I tried something like this
$userNode = $rootNode->addChild('Users list', array(
'route' => 'users_list',
));
$userNode->addChild('User edition', array(
'route' => 'user_edit',
));
Symfony complains about the missing parameter :(
Thanks !
This setup should work if you want to show the links:
$userNode->addChild('User edition', array(
'route' => 'user_edit',
'routeParameters' => array('id' => $someParameter)
));
For setting the parrent node as active you could use a custom renderer, override the menubundle's template or just add active class to the menu item based on this condition:
{% if app.request.attributes.get('_route') == 'user_edit' %}
{# activate parrent node #}
{% endif %}
Found something usefull in my case, you're able to solve this using the request in your menu definition class :
$userNode->addChild('User edition', array(
'route' => 'user_edit',
'routeParameters' => array('id' => $this->getRequest()->get('id'))
));