f3-factory / fatfree-core

Fat-Free Framework core library
GNU General Public License v3.0
208 stars 88 forks source link

Templating: passing arguments to stacked filters #70

Open xfra35 opened 9 years ago

xfra35 commented 9 years ago

Hi guys,

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

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 commented 9 years ago

you want to put code into your templates? urgh :grin: 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 commented 9 years ago

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:

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

xfra35 commented 9 years ago

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

ikkez commented 9 years ago

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

{{ 'pick{title}, join{\' / \'}', @author->getBooks() | chain }}
<F3:content model="books" foo="bar" as="book" />
<div>Title: <strong>{{@book.title}}</strong></div>
</F3:content>
{{ @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 commented 9 years ago

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 commented 9 years ago

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 commented 9 years ago

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 commented 9 years ago

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 commented 9 years ago

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

then in the view I do <?php array_walk($data,$formatter) ?>

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 commented 8 years ago

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 commented 8 years ago

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 commented 8 years ago

injecting the helper methods into the views

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

ikkez commented 8 years ago

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 commented 8 years ago

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 commented 8 years ago

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 commented 8 years ago

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 commented 8 years ago

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 commented 8 years ago

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 commented 8 years ago

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 commented 8 years ago

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 commented 8 years ago

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 commented 8 years ago

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

xfra35 commented 7 years ago

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 commented 7 years ago

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 commented 7 years ago

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 commented 7 years ago

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:

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

ikkez commented 7 years ago

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 commented 7 years ago

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 https://github.com/bcosca/fatfree-core/pull/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 commented 7 years ago

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 commented 7 years ago

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.