Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We鈥檒l occasionally send you account related emails.

Already on GitHub? Sign in to your account

Templating: passing arguments to stacked filters #70

Open
xfra35 opened this issue Jun 30, 2015 · 30 comments

Comments

Projects
None yet
4 participants
@xfra35
Copy link
Collaborator

commented Jun 30, 2015

Hi guys,

Since @ikkez has introduced the configurable template filters, I love this feature. It's very powerful 馃憤

The only thing is that arguments can be only passed to the first filter.

For example, let's say we have a mapper with 1:m relation, on which we want to pick one column and join the resulting array. The following does work:

{{ @author->getBooks(), title | pick, join }}

but we can't pass any argument to the second filter (in this example, passing ' / ' to join would override the default gluing string).

Any ideas on how to implement that?

@KOTRET

This comment has been minimized.

Copy link
Contributor

commented Jun 30, 2015

you want to put code into your templates? urgh 馃榿
seriously: i would put this into a controller...

to add some content to the post:
{{ @author->getBooks(), title | join(pick,' / ') }}
but hell... looks like this will cost too much time to evaluate the expression

@xfra35

This comment has been minimized.

Copy link
Collaborator Author

commented Jun 30, 2015

Well the controller is here to fetch relevant business data and pass it to the template. Then the template formats the data. The formatting part can be quite complex, depending on the input data: pick array column, format price, etc.. so yes that's code. And custom filters greatly improve readability for that matter.

Here's another example. Let's say we have an excerpt filter which strip tags from an HTML string and truncates the result to a given number or characters. Now if you have ESCAPE enabled, you'll need to raw the string first. But then, you can't pass the number of characters to the 2nd filter:

  • {{ @str | raw }} => works
  • {{ @str, 140 | raw, excerpt }} => doesn't work (140 is passed to raw, not excerpt)

Of course, workarounds are always possible, but I was wondering if we could do something about it.

@xfra35

This comment has been minimized.

Copy link
Collaborator Author

commented Jun 30, 2015

As of now, the filter list is splittable, so join(pick,' / ') would break BC.

@ikkez

This comment has been minimized.

Copy link
Collaborator

commented Jun 30, 2015

hi flo. What about a workaround:

<F3:set books="{{ @author->getBooks(), title | pick }}" />
<F3:set books="{{ @books, ' / ' | join }}" />
{{ @books }}

But setting variables and doing things to prepare the view data, also looks like controller code to me.
You could also solve this like this

  • you could extend your author model with a function to return the data as you like (@author->getBookTitles()).
  • create a collection object with such methods, that is returned from your author model (in Cortex this would be $author->books->getAll('title'))
  • just use the raw php functions if possible:
{{ join(' \ ', \Helper::instance()->pick('title',@author->getBooks())) }} 
  • create a generic filter, that works similar to the format filter, which has a bit more adjustable syntax:
{{ 'pick{title}, join{\' / \'}', @author->getBooks() | chain }}
  • create a tag handler instead, with gives you more power with attributes and a content zone:
<F3:content model="books" foo="bar" as="book" />
<div>Title: <strong>{{@book.title}}</strong></div>
</F3:content>
  • or we modify the base code to support the syntax like
{{ @author->getBooks(), 'title', ' / ' | pick,join }}

but use func_num_args() to get all unused parameters, and pass this to the chained join method (but not sure if that is fully backward compatible or practical)

@xfra35

This comment has been minimized.

Copy link
Collaborator Author

commented Jun 30, 2015

Yeah of course workarounds are possible. That's what I've been doing all the time ^^.

It's just occured to me that, with filters we could turn such an ugliness :

{{ explode(' / ',array_map(function(@b){return @b.title;}, @author.books)) }}

into something very easy to read and maintain:

{{ @author.books, title | pick, join }}

There are many cases where extracting a column from an array doesn't fit in the controller. For example, if you have a generic controller with custom templates.

Anyway, the question was not specific about that column picker.

@ikkez

This comment has been minimized.

Copy link
Collaborator

commented Jun 30, 2015

Okay I see your point. Well what do you think about the responsibility of that filter arguments. Should the filter implementation care about the arguments, and push unused addional arguments to the next filter (i.e. always return an array [$ownFilterResults, func_get_arg(1),func_get_arg(2), ...]), or should the template parser get a new syntax for defining multiple parameters for token? The current implementation sends all arguments to the first filter, and form there it only returns the result which used as argument for the next filter

@xfra35

This comment has been minimized.

Copy link
Collaborator Author

commented Jul 1, 2015

Well the first suggestion is interesting as it's easy to implement and it shouldn't break existing code (except custom filters). Though it may be tricky with optional arguments:

{{ arg1,null,arg2 | alias, myfunc }} <!-- null is mandatory for arg2 to be passed to myfunc -->

I've had a look at strategies used by various template engines and they all seem to end up with either {{ arg1 | funcA | funcB(arg2,arg3) }} or {{ arg1 | funcA | funcB:arg2,arg3 }}. But I guess that would require a regex monster to have it work..

Need to think more about it.

@ikkez

This comment has been minimized.

Copy link
Collaborator

commented Jul 1, 2015

what about a generic filter that calls other filters in conjunction?

{{ 'pick{title}, join{\' / \'}', @author->getBooks() | chain }}

yeah it's like from behind through the chest, but would work.

@slifin

This comment has been minimized.

Copy link
Contributor

commented Oct 2, 2015

When it comes to formatting I tend to inject the data and a formatter as a callback

then in the view I do

If I need additional parameters then I curry them into my $formatter ahead of time to keep my logic out of the views

though I only do that if the $formatter is doing something that the result couldn't be considered data in itself, if that is the case then sometimes it's just better to array_map the formatted data into the dataset

@xfra35

This comment has been minimized.

Copy link
Collaborator Author

commented Nov 19, 2015

Yeah of course, $formatter can be prepared in the controller. But I have a feeling that simple formatting should belong to the views. After all, that's why we have the | format filter.

Considering it again, I think that changing the filter separator from comma to pipe would provide more flexibility.

Instead of {{ @author.books, title | pick, join }}, we would have {{ @author.books, title | pick | join }}.

This way, we could pass arguments to subsequent filters:
{{ @author.books, title | pick, ',' | join }}

What do you think?

@slifin

This comment has been minimized.

Copy link
Contributor

commented Nov 19, 2015

In rails $formatters are stored in helper classes see:
http://codefol.io/posts/Where-Do-I-Put-My-Code
I do the same thing in PHP by injecting the helper methods into the views when needed
I would personally recommend not coupling yourself too hard to the f3 template syntax
(or any other view syntax beyond pure PHP) in my experience it creates challenges for:
training new people/maintenance/syntax highlighting/performance

To help retain semantic meaning of views I would recommend using F3's View class instead of it's Template class

Keep in mind I don't speak as a developer of F3 I'm just a long time user

@xfra35

This comment has been minimized.

Copy link
Collaborator Author

commented Nov 19, 2015

injecting the helper methods into the views

Looks like we're on the same page =) Template filters are precisely helpers injected into views.

@ikkez

This comment has been minimized.

Copy link
Collaborator

commented Nov 19, 2015

Another pipe char as filter separator is no good idea. The regex that splits the expression and the filters is hardly trimmed to recognize the last single pipe char and it's good like that because the expression itself may contain multiple pipe chars. One way could be to wrap the arguments: | filterA('x'), filterB(@y).
But I think it's gonna be too hard to merge the arguments together in a meaningful way that people understand. In case you have {{ @foo, 'bar' | filterA('x'), filterB(@y) }}. How is this resolved? like this?
echo \MyFilter::filterB(\MyFilter::filterA($foo,'bar','x'), $y).

Maybe a simple custom chain filter is more comprehensible:
{{ "@foo,'bar','x' | filterA", "@y | filterB" | chain }}

@xfra35

This comment has been minimized.

Copy link
Collaborator Author

commented Nov 19, 2015

the expression itself may contain multiple pipe chars

They could be escaped or enclosed in quotes. Also I don't understand how the chain filter would solve this issue.

@ikkez

This comment has been minimized.

Copy link
Collaborator

commented Nov 19, 2015

Why make it complicated when filterB(@y) is fine?
Well yes, after reviewing it again, I guess the chain filter doesn't make a difference here.

@KOTRET

This comment has been minimized.

Copy link
Contributor

commented Nov 20, 2015

seems its getting really complex and imho this is not very maintainable.
Why not just create a filter that does these two things?
{{ @author->getBooks(), title, ' / ' | pickjoin }}

If you really want to pipe outputs then you have to mask the pipe-chars that are between two ' or ". After that split up and process. This needs some extra work and will cost time.
Anyway, in this case i'd tend to use sort of this pattern:
{{ @author->getBooks() | pick "title" | join @joinstr | removechar '|' }}
鈫 Pipe @author->getBooks() into pick-filter and add the string title as 2nd argument.
鈫 Pipe output into join-filter and add the var joinstr as 2nd argument
鈫 Pipe output into removechar-filter and add the char | as 2nd argument

the output is always the first arg or the arg has to be declared in the pattern:
... | myfilter 'foobar' %1 | ...

@xfra35

This comment has been minimized.

Copy link
Collaborator Author

commented Dec 18, 2015

Here's another use case. Let's say you have a price filter to format prices:

{{ @deposit | price }}

Now if you need to combine this filter with format, for example to output something like Please pay the $400 deposit before..... well you just can't. You need to call the original filter handler:

{{ @intro, My\Long\Namespace::formatPrice(@deposit) | format }}

Would be nice to ease that kind of stuff... although that looks even trickier to achieve than the first use case ;)

@ikkez

This comment has been minimized.

Copy link
Collaborator

commented Dec 18, 2015

That's not a good sample, because you can already do that pretty neat with the format filter and a dictionary ;)

intro = "Please pay the {0,number,currency} deposit before."
<p>{{ @intro, 400 | format }}</p>
@xfra35

This comment has been minimized.

Copy link
Collaborator Author

commented Dec 18, 2015

OK ;) But actually the price filter is app-specific (user-selected currency + automatic rate conversion).

Anyway you see what I mean: inject a custom filter into another one. It could be anything else, like a country code formatter:

{{ @code | country }}

How to smartly combine it with Passengers from {0} should request a visa?

@ikkez

This comment has been minimized.

Copy link
Collaborator

commented Dec 18, 2015

actually the price filter is app-specific (user-selected currency + automatic rate conversion)

ok, but there could be more solutions for this. I think that this could also be made directly in your model, because you probably need that price for more than just the frontend view.

but yeah I see what you mean. So in essence you want something like

<p>{{ @intro, {{@code | country}} | format }}</p>
@xfra35

This comment has been minimized.

Copy link
Collaborator Author

commented Dec 18, 2015

I think that this could also be made directly in your model, because you probably need that price for more than just the frontend view

Actually not. In this case, this is a typical job for the views. Raw numbers are manipulated inside models and formatted prices are displayed inside views.

As for the syntax, you're right: the issue is about how to group function calls.

We need something that performs like:

format($intro,country($code))
join(pick($ppl,'name'),'-')

One way to do it is what you're suggesting (nested braces). Or maybe just with parenthesis:

@intro, (@code | country) | format
(@ppl, 'name' | pick),'-' | join

Another way could be to ease the calls to filters:

@intro, @this->country(@code) | format
@this->pick(@ppl,'name'), '-' | join

There's also the suggestion from @KOTRET:

@code | country, @intro %1 | format
@ppl, 'name' | pick, '-' | join
@KOTRET

This comment has been minimized.

Copy link
Contributor

commented Dec 22, 2015

@code | country | format @intro %1
@ppl | pick 'name' | join '-'

@xfra35

This comment has been minimized.

Copy link
Collaborator Author

commented Oct 25, 2017

Exhuming this topic with a cleaner solution.

Since the context of each rendered template is the templating class itself (Preview or Template), we can call any method of that class from withing the template. E.g:

{{ @this->raw('&amp;') }}

So we could implement the Preview::__call magic method so that any filter can be called that way. This way, complex combinaisons of filters such as those described above could be easily solved without resorting to ugly hacks:

{{ @this->pick(@author->getBooks(), title), '/' | join }}
{{ @this->raw(@str), 140 | excerpt }}
{{ @intro, @this->price(@deposit) | format }}
{{ @intro, @this->country(@code) | format }}

What do you think?

The major drawback of this solution is that it introduces a risk of naming collision with the class core methods. However it should be possible to find a solution to avoid this issue.

@ikkez

This comment has been minimized.

Copy link
Collaborator

commented Oct 25, 2017

When I had to use more than one simple filter, I currently tend to register a custom filter that will do what is needed... so I end up having multiple custom filters, but that is fine.
For the multiple filter usage syntax, I was looking forward to the same way angular solves it,.. but it also wouldn't solve "filter within filter" usage.. that always lead to answers where you actually end up writing a custom filter (where you can then interlace filter)... but I see your point and it solves the issue, but I'm not sure if that really makes it better :D

@ikkez

This comment has been minimized.

Copy link
Collaborator

commented Oct 26, 2017

btw: @xfra35 regarding your country code sample: wouldn't it be possible to just put that country selector into a simple function instead of a filter? Then it could go like this:

$f3->set('countryCode', function($code) { return $whatever });
{{ @intro, @countryCode(@code) |聽format }}
@xfra35

This comment has been minimized.

Copy link
Collaborator Author

commented Oct 27, 2017

Of course but the point is: couldn't we make the framework a bit more flexible about filters, so that we don't have to resort to workarounds whenever the use case is out of scope.

Let's take an example. We have a website about countries & currency rates. To make things easy, we create a filter which converts a country code to a country name.

So it most of templates, we have snippets like {{ @code | country }}.

Now in some particular template, we need to include the country name in a whole sentence. So we need to combine the country filter with the format one. Something like {{ @intro, {{ @code | country }} | format }}, but that's not possible, so we're left with:

  • either splitting the @intro sentence in two: {{ @intro1 }}{{ @code | country }}{{ @intro2 }}
  • or calling the filter handler directly: {{ @intro, My\Filters::country(@code) | format }}
  • or defining a new function in the controller, like you suggested : {{ @intro, mynewfunc(@code) | format }}

None of those solutions is smooth. They are workarounds, and that situation defeats the purpose of filters.

@ikkez

This comment has been minimized.

Copy link
Collaborator

commented Oct 27, 2017

I think your "sample" is not a filter issue, but a formatting issue.. what you need is a custom FORMATTER:

$f3->set('FORMATS.country',function($code){
	$code=strtolower($code);
	$countries=[
		'de'=>'Germany',
		'fr'=>'France',
		'en'=>'England',
	];
	return isset($countries[$code]) ? $countries[$code] : $code;
});

$intro='Welcome to {0, country} - the best country in the World.';
echo $f3->format($intro,'de');

Then the issue about stacking filters due to the usage for formatting purpose solves itself, as it becomes: {{ @intro, @code | format }}

I think a syntax for stacking filters in general like {{ @value1, {{ @value2 | filter2 }} | filter1 }} isn't very good. A custom filter would be better here IMO.

The real issue left here are chained filters:
use the result of one filter and pass it into another filter
currently: {{ @value |聽filter1, filter2 }}

There's obviously an issue here with chaining multiple filter and setting arguments to a filter that's not the first one: {{ @value, @arg1, 'arg2' | filter1, filter2 }}. It's not possible to set an argument for filter2.
It could be solved by adapting a syntax similar to angular, i.e.:
{{ @value | filter1:@arg1:'arg2' | filter2:'arg2' }}

But it's not backwards compatible..

@xfra35

This comment has been minimized.

Copy link
Collaborator Author

commented Oct 27, 2017

I think your "sample" is not a filter issue, but a formatting issue

Not really... I don't want to write:

{{ {0, country}, @code | format }}

when I can write:

{{ @code | country }}

Moreover, string formatters are mostly useful to ease localization, by providing the ability to use different formatters on a per-language basis (e.g #156).

Apart for these language-specific cases, the responsibility for data formatting falls, imho, on the template rather than the translation files. Writing {0, country} in all translation files is like moving code from template to translation files. What if we decide later to replace the full country name with the country code? Then we'd have to correct all translation files. This doesn't feel the right place to do so.

The Angular syntax looks interesting but it doesn't solve the nesting issue.

@ikkez

This comment has been minimized.

Copy link
Collaborator

commented Oct 27, 2017

Well actually you only need to adjust the country formatter in that case and not all the dictionary files.. I think parsing text and formatting it is a good job for the new custom formatters and a nice way to spice up the language files. It also opens the way to use dictionary keys within other dictionary keys, like putting translated month names into a string... If your country filter doesn't do something else, it would fit there fine as well, but do it as you want of course.
But I don't want to nail this issue only because of one example... and I must admit that I'm out of ideas here to find a better way when nesting filter is really necessary within the template (despite creating a custom filter for that job)... so the Magic call and {{ @value, @this->filter2(@code) | filter1 }} is probably the simplest way to go.

NB: originally filters were introducted to transform data, remember esc, raw and format being the first filters introduced... they are used to be able to filter/encode the data based on the context you're using the data.

@xfra35

This comment has been minimized.

Copy link
Collaborator Author

commented Oct 27, 2017

originally filters were introducted to transform data
they are used to be able to filter/encode the data based on the context you're using the data.

I'm pretty convinced that the country filter described above falls into this category ^^

But OK, let's wait a bit more and see if someone comes with a better idea.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
You can鈥檛 perform that action at this time.