Working With View Composers in Sage 10

Finally starting with Sage 10 and trying to wrap my head around this.

So using View Composers I can just drop @include('partials.whaterver') anywhere in the theme but then determine in the View Composer whether it should render or not.

For example, @include('partials.page-builder) only renders if get_field('page_builder') exists. Composers are bound to Partials.

So then, what is the preferred pattern / method to then only conditionally render one partial based on another.

Again in this example, if the page-builder partial is going to render, I don’t want the content-page to render.

Do I

  • Do a get_field() check inside the Page’s Composer to see if there’s a page_builder field, and return false, to stop it from rendering? Or
  • Check $data or $view for the existence of the page_builder field?
  • Something else?

While you can do this if you construct your partials to check some variable before rendering, IMO it’s kind of a weird way to think about Composers and partials, and how they work.

A Composer is just a way that you can “compose” what data is available to a given partial. While you could use that inside of a partial to determine whether that partial renders out and HTML or not, IMO it makes more sense to move that logic up a level, and call (or not call) a partial depending on come condition in the calling view.

In your use case, I’d consider doing something like this:

// app/composers/Page.php
class Page extends Composer {
   public function with() {
      return [ 'page_builder' => get_field('page_builder') ?: false ];
   }
}

// resources/views/page.blade.php
@if($page_builder)
   @include('partials.page-builder')
@else
   @include('partials.content-page')
@endif

// resources/view/partials/page-builder.blade.php
@foreach($page_builder as $block)
   // or whatever
@endforeach

IMO when a component is requested it should be able to assume that it will exist and do what it is meant to do: Whether or not it should exist is a decision that should be delegated to whatever calls (or doesn’t call, as the case may be) it.

2 Likes

Oh. I thought this was precisely the benefit! I liked the mental model of partials themselves knowing whether they’ll render or not based on a Composer, instead of the entire view checking a variable exists and then conditionally rendering.

Thanks for the example, the templated part of this now looks exactly like how we did it with Controllers in Sage 9.

You can totally do it if you want! If the mental model works better for you then by all means. The best way to do a thing is the way that works best for you. I’ve used a similar pattern in situations where I’m calling partials in contexts where the state of my data is not assured and I just want to things to quietly go away instead of throwing “variable doesn’t exist errors,” but I’ve thought of it more as a sort of problem-recovery mechanic rather than an intentional architecture mechanic. YMMV though.

1 Like

If I dump $this->view I get a huge object. So I have to run a method on it. The below works, is this correct?

$this->view->name() === 'partials.entry-meta'

yeah as seen here

Of course! Thanks.

(20 characters)

@alwaysblank Wow, thank you so much for the thorough explanation!

1 Like

Hi guys,

I’m trying to create a base View Composer like described in this post, but somehow it’s not working for me. This is my base view composer:

namespace App\View\Composers;
use Roots\Acorn\View\Composer;

class ProductCardBase extends Composer {

	protected static $dataMethods = [
		'title',
		'description',
		'image',
		'product_link'
	];

	public function with() {
		$id = $this->data['product'];
		$product = wc_get_product($id);

		// Uncommenting this block does work?
		// return [
		// 	'title'        => $this->title($id, $product),
		// 	'description'  => $this->description($id, $product),
		// 	'image'        => $this->image($id, $product),
		// 	'page_link'    => $this->page_link($id, $product),
		// 	'product_link' => $this->product_link($id, $product)
		// ];

		return array_map(function($method) use ($id, $product) {
			if (method_exists($this, $method)) {
				return [$method => $this->{$method}($id, $product)];
			}

			return [$method => false];
		}, $this::$dataMethods);
	}

	/**
	 * Returns the product title.
	 *
	 * @return string
	 */
	public function title($id, $product) {
		return $product->get_name();
	}

	/**
	 * Returns the product short description.
	 *
	 * @return string
	 */
	public function description($id, $product) {
		return $product->get_short_description();
	}

	/**
	 * Returns the product image.
	 *
	 * @return string
	 */
	public function image($id, $product) {
		return $product->get_image('medium');
	}

	/**
	 * Returns the product url.
	 *
	 * @return string
	 */
	public function product_link($id, $product) {
		return $product->get_permalink();
	}
}

I extend it in a ProductBlock composer:

namespace App\View\Composers;

class ProductBlock extends ProductCardBase {
	/**
	 * List of views served by this composer.
	 *
	 * @var array
	 */
	protected static $views = [
		'blocks.product-block'
	];
}

In a loop, I include the partial and pass the product id:

@foreach ($products as $product)
	<div class="swiper-slide">
		@include('blocks.product-block', [
			'product' => $product
		])
	</div>
@endforeach

And my blocks.product-block partial:

@isset($product)
	<article class="b-product">
		<header class="b-product__header">
			<h3 class="b-product__title">{!! $title !!}</h3>
			<div class="b-product__desc">{!! $description !!}</div>
		</header>
		@if ($image)
			<figure class="b-product__image">{!! $image !!}</figure>
		@endif
		<footer class="b-product__footer">
			@if ($product_link)
				<a class="btn btn-outline-primary" href="{{ $product_link }}" role="button">{!! __('Configure', 'mydomain') !!}</a>
			@endif
		</footer>
	</article>
@endisset

Strangely, if I just return the commented array with the function calls directly, it does work?
I thought it had to do with how the class functions are called by string name:

$this->{$method}($id, $product)

so instead I tried:

call_user_func([$this, $method], $id, $product);

with same result, not data available in my view?
What am I missing here? Is this array_map technique not possible anymore with the latest Composer?

Thanks!

with() needs to return a keyed array: those keys are used as the variable names in your blade. Your usage of array_map() doesn’t return keys, it returns an array of arrays. array_map() in PHP can’t modify or generate keys, it only handles values. You appear to be trying to generate keys through it, which won’t work.

Thanks, you’re right! I just figured that out too…
I literally copied it form the original article:

public function with($data, $view)
    {
        return array_map(function($method) use ($data, $view) {
            if (method_exists($this, $method)) {
                return [$method => $this->{$method}($data, $view)];
            }

            return [$method => false];
        }, $this::$dataMethods);
    }

I think I need to use array_walk instead?

A simple foreach loop works:

$methods = [];
foreach($this::$dataMethods as $key => $method) {
	if (method_exists($this, $method)) {
		$methods[$method] = $this->{$method}($id, $product);
	} else {
		$methods[$method] = false;
	}
}

return $methods;

array_walk() still can’t modify keys, I think. You could probably use array_column():

return array_column(array_map(function($method) use ($data, $view) {
            if (method_exists($this, $method)) {
                return [$method, $this->{$method}($data, $view)];
            }

            return [$method, false];
        }, $this::$dataMethods), 1, 0);

Anyway, thanks for noticing that bug! I’m surprised you’re the first person to try and use that code and realize it’s broken. I’ll fix it in the post.

2 Likes

I was surprised too :slight_smile:
Is it not a new PHP 7.4 restriction?

EDIT: no it’s not

1 Like

I still feel like I’m missing something probably pretty obvious when it comes to this, but how do I pass params from my views to the Composer? From what I’ve read and tried to troubleshoot on my own, it doesn’t seem that Composers allow for static functions?

For example, in Sage 9 I could do something like {!! App::getSomePosts('testimonials', 3) !!} and then in my Controller have

public static function getSomePosts($type, $num) {
        $args = [
            'post_type' => $type,
            'posts_per_page' => $num,
        ];
        $query = new \WP_Query($args);

        return $query;
    }

Then I could loop over the result in my view. How can I create these sort of helper functions with Composers? It seems like passing params isn’t an option?

Make it available using with or override, I believe.

In a view composer:

class MyView {
  protected static $views = [
      'partials.content',
  ];

  public function with()
  {
      return [
          'posts' => $this->posts(),       
      ];
  }

  public function posts()
  {
        $args = [
            'post_type' => $type,
            'posts_per_page' => $num,
        ];
        $query = new \WP_Query($args);

        return $query;
  }
}

In a view:

  <div class="entry-content">
    <!-- do something with the posts -->
    {!! $posts !!}
  </div>
2 Likes

But if the post type and number are unknown until I need to use it in a view, how do I pass the type and num params? I want this function be available in all views, so for example I could use the same function and pass “post” and “10” instead of “testimonials” and “3” in a completely different view.

If this is your use case, then a Composer isn’t really the place for your function. Composers are for modifying and providing data to views, they’re not meant to be wrappers for methods. I’d put your function somewhere else (i.e. helpers.php) and then just call it from your blades like get_my_posts('post', 13).

If you’re absolutely determined to put this functionality in your view, though, then you just need to return a function:

// Composer
public function with()
{
  return [
    'getPosts' => function($post_type, $num) {
      return new \WP_Query([
        'post_type' => $post_type,
        'posts_per_page' => $num,
      ]),
 ];
}
// blade
@foreach( $getPosts('post', 10) as $post )
  // Do Stuff
@endforeach
3 Likes

Okay, that makes much more sense. Thank you for your help and all the work you do to make Sage great!

1 Like

I would like to display a custom post type by categories so each category can be displayed under different tabs. So far this is the best example I have seen. I have added a query similar to your example.

  protected static $views = [
        'partials.page-header',
        'partials.hero',
        'partials.content',
        'partials.content-*',
    ];

public function override()
    {
        return [
            'title' => $this->title(),
            'projects' => $this->projects(),
        ];
    }

    public function projects(): WP_Query
    {
        $args = [
            'post_type' => 'projects',
            'category_name' => 'email',
            'posts_per_page' => 10,
        ];
        return new \WP_Query($args);
    }

In a view

<article @php(post_class())>
  <header>
    <h2 class="entry-title">
      <a href="{{ get_permalink() }}">
        {!! $title !!}
      </a>
    </h2>

    @include('partials.entry-meta')
  </header>
  {!! $projects !!}
  <div class="entry-summary">
    @php(the_excerpt())
  </div>
</article>

The error I get returned is:

Any help or insight would be great. Thank you.