Skip to content

Commit fa6e707

Browse files
sebastienrosGitHub Copilot CLI
andauthored
Add tablerow tag support (#906)
* Add tablerow tag support Implements the Liquid tablerow tag that generates HTML table rows for every item in an array. Features: - Basic iteration: {% tablerow item in collection %}{% endtablerow %} - cols: parameter for column count - limit: parameter to limit iterations - offset: parameter to start at an offset - Full tablerowloop object with all properties (index, index0, col, col0, row, first, last, col_first, col_last, length, rindex, rindex0) The implementation follows the Shopify Liquid Ruby reference implementation. * Add unit tests for tablerow tag --------- Co-authored-by: GitHub Copilot CLI <copilot@github.com>
1 parent 6f48598 commit fa6e707

File tree

7 files changed

+761
-0
lines changed

7 files changed

+761
-0
lines changed
Lines changed: 304 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,304 @@
1+
using System.IO;
2+
using System.Linq;
3+
using System.Threading.Tasks;
4+
using Fluid.Ast;
5+
using Fluid.Values;
6+
using System.Text.Encodings.Web;
7+
using Xunit;
8+
9+
namespace Fluid.Tests
10+
{
11+
public class TableRowStatementTests
12+
{
13+
private static readonly FluidParser _parser = new FluidParser();
14+
15+
[Fact]
16+
public async Task ShouldRenderBasicTableRow()
17+
{
18+
var e = new TableRowStatement(
19+
[new OutputStatement(new MemberExpression(new IdentifierSegment("i")))],
20+
"i",
21+
new MemberExpression(new IdentifierSegment("items")),
22+
limit: null,
23+
offset: null,
24+
cols: null
25+
);
26+
27+
var sw = new StringWriter();
28+
var context = new TemplateContext();
29+
context.SetValue("items", new[] { 1, 2, 3 });
30+
await e.WriteToAsync(sw, HtmlEncoder.Default, context);
31+
32+
Assert.Equal("<tr class=\"row1\">\n<td class=\"col1\">1</td><td class=\"col2\">2</td><td class=\"col3\">3</td></tr>\n", sw.ToString());
33+
}
34+
35+
[Fact]
36+
public async Task ShouldRenderWithCols()
37+
{
38+
var e = new TableRowStatement(
39+
[new OutputStatement(new MemberExpression(new IdentifierSegment("i")))],
40+
"i",
41+
new MemberExpression(new IdentifierSegment("items")),
42+
limit: null,
43+
offset: null,
44+
cols: new LiteralExpression(NumberValue.Create(2))
45+
);
46+
47+
var sw = new StringWriter();
48+
var context = new TemplateContext();
49+
context.SetValue("items", new[] { 1, 2, 3, 4 });
50+
await e.WriteToAsync(sw, HtmlEncoder.Default, context);
51+
52+
Assert.Equal("<tr class=\"row1\">\n<td class=\"col1\">1</td><td class=\"col2\">2</td></tr>\n<tr class=\"row2\"><td class=\"col1\">3</td><td class=\"col2\">4</td></tr>\n", sw.ToString());
53+
}
54+
55+
[Fact]
56+
public async Task ShouldRenderWithLimit()
57+
{
58+
var e = new TableRowStatement(
59+
[new OutputStatement(new MemberExpression(new IdentifierSegment("i")))],
60+
"i",
61+
new MemberExpression(new IdentifierSegment("items")),
62+
limit: new LiteralExpression(NumberValue.Create(2)),
63+
offset: null,
64+
cols: null
65+
);
66+
67+
var sw = new StringWriter();
68+
var context = new TemplateContext();
69+
context.SetValue("items", new[] { 1, 2, 3, 4 });
70+
await e.WriteToAsync(sw, HtmlEncoder.Default, context);
71+
72+
Assert.Equal("<tr class=\"row1\">\n<td class=\"col1\">1</td><td class=\"col2\">2</td></tr>\n", sw.ToString());
73+
}
74+
75+
[Fact]
76+
public async Task ShouldRenderWithOffset()
77+
{
78+
var e = new TableRowStatement(
79+
[new OutputStatement(new MemberExpression(new IdentifierSegment("i")))],
80+
"i",
81+
new MemberExpression(new IdentifierSegment("items")),
82+
limit: null,
83+
offset: new LiteralExpression(NumberValue.Create(2)),
84+
cols: null
85+
);
86+
87+
var sw = new StringWriter();
88+
var context = new TemplateContext();
89+
context.SetValue("items", new[] { 1, 2, 3, 4 });
90+
await e.WriteToAsync(sw, HtmlEncoder.Default, context);
91+
92+
Assert.Equal("<tr class=\"row1\">\n<td class=\"col1\">3</td><td class=\"col2\">4</td></tr>\n", sw.ToString());
93+
}
94+
95+
[Fact]
96+
public async Task ShouldRenderWithRange()
97+
{
98+
var e = new TableRowStatement(
99+
[new OutputStatement(new MemberExpression(new IdentifierSegment("i")))],
100+
"i",
101+
new RangeExpression(
102+
new LiteralExpression(NumberValue.Create(1)),
103+
new LiteralExpression(NumberValue.Create(4))
104+
),
105+
limit: null,
106+
offset: null,
107+
cols: new LiteralExpression(NumberValue.Create(2))
108+
);
109+
110+
var sw = new StringWriter();
111+
await e.WriteToAsync(sw, HtmlEncoder.Default, new TemplateContext());
112+
113+
Assert.Equal("<tr class=\"row1\">\n<td class=\"col1\">1</td><td class=\"col2\">2</td></tr>\n<tr class=\"row2\"><td class=\"col1\">3</td><td class=\"col2\">4</td></tr>\n", sw.ToString());
114+
}
115+
116+
[Fact]
117+
public async Task ShouldProvideTableRowLoopVariables()
118+
{
119+
_parser.TryParse("{% tablerow i in (1..2) %}col:{{ tablerowloop.col }} col0:{{ tablerowloop.col0 }} row:{{ tablerowloop.row }} first:{{ tablerowloop.first }} last:{{ tablerowloop.last }} index:{{ tablerowloop.index }} index0:{{ tablerowloop.index0 }} rindex:{{ tablerowloop.rindex }} rindex0:{{ tablerowloop.rindex0 }} length:{{ tablerowloop.length }} col_first:{{ tablerowloop.col_first }} col_last:{{ tablerowloop.col_last }}{% endtablerow %}", out var template, out var error);
120+
121+
var result = await template.RenderAsync();
122+
123+
Assert.Contains("col:1", result);
124+
Assert.Contains("col0:0", result);
125+
Assert.Contains("row:1", result);
126+
Assert.Contains("first:true", result);
127+
Assert.Contains("length:2", result);
128+
Assert.Contains("col_first:true", result);
129+
}
130+
131+
[Fact]
132+
public async Task ShouldHandleEmptyCollection()
133+
{
134+
var e = new TableRowStatement(
135+
[new OutputStatement(new MemberExpression(new IdentifierSegment("i")))],
136+
"i",
137+
new MemberExpression(new IdentifierSegment("items")),
138+
limit: null,
139+
offset: null,
140+
cols: null
141+
);
142+
143+
var sw = new StringWriter();
144+
var context = new TemplateContext();
145+
context.SetValue("items", System.Array.Empty<int>());
146+
await e.WriteToAsync(sw, HtmlEncoder.Default, context);
147+
148+
Assert.Equal("", sw.ToString());
149+
}
150+
151+
[Fact]
152+
public async Task ShouldHandleBreak()
153+
{
154+
_parser.TryParse("{% tablerow i in (1..5) cols:2 %}{{ i }}{% if i == 2 %}{% break %}{% endif %}{% endtablerow %}", out var template, out var error);
155+
156+
var result = await template.RenderAsync();
157+
158+
Assert.Equal("<tr class=\"row1\">\n<td class=\"col1\">1</td><td class=\"col2\">2</td></tr>\n", result);
159+
}
160+
161+
[Fact]
162+
public async Task ShouldParseTableRowTag()
163+
{
164+
_parser.TryParse("{% tablerow item in collection %}{{ item }}{% endtablerow %}", out var template, out var error);
165+
166+
Assert.Null(error);
167+
Assert.NotNull(template);
168+
169+
var context = new TemplateContext();
170+
context.SetValue("collection", new[] { "a", "b", "c" });
171+
var result = await template.RenderAsync(context);
172+
173+
Assert.Equal("<tr class=\"row1\">\n<td class=\"col1\">a</td><td class=\"col2\">b</td><td class=\"col3\">c</td></tr>\n", result);
174+
}
175+
176+
[Fact]
177+
public async Task ShouldParseTableRowWithColsParameter()
178+
{
179+
_parser.TryParse("{% tablerow item in collection cols:2 %}{{ item }}{% endtablerow %}", out var template, out var error);
180+
181+
Assert.Null(error);
182+
Assert.NotNull(template);
183+
184+
var context = new TemplateContext();
185+
context.SetValue("collection", new[] { "a", "b", "c", "d" });
186+
var result = await template.RenderAsync(context);
187+
188+
Assert.Equal("<tr class=\"row1\">\n<td class=\"col1\">a</td><td class=\"col2\">b</td></tr>\n<tr class=\"row2\"><td class=\"col1\">c</td><td class=\"col2\">d</td></tr>\n", result);
189+
}
190+
191+
[Fact]
192+
public async Task ShouldParseTableRowWithLimitParameter()
193+
{
194+
_parser.TryParse("{% tablerow item in collection limit:2 %}{{ item }}{% endtablerow %}", out var template, out var error);
195+
196+
Assert.Null(error);
197+
Assert.NotNull(template);
198+
199+
var context = new TemplateContext();
200+
context.SetValue("collection", new[] { "a", "b", "c", "d" });
201+
var result = await template.RenderAsync(context);
202+
203+
Assert.Equal("<tr class=\"row1\">\n<td class=\"col1\">a</td><td class=\"col2\">b</td></tr>\n", result);
204+
}
205+
206+
[Fact]
207+
public async Task ShouldParseTableRowWithOffsetParameter()
208+
{
209+
_parser.TryParse("{% tablerow item in collection offset:2 %}{{ item }}{% endtablerow %}", out var template, out var error);
210+
211+
Assert.Null(error);
212+
Assert.NotNull(template);
213+
214+
var context = new TemplateContext();
215+
context.SetValue("collection", new[] { "a", "b", "c", "d" });
216+
var result = await template.RenderAsync(context);
217+
218+
Assert.Equal("<tr class=\"row1\">\n<td class=\"col1\">c</td><td class=\"col2\">d</td></tr>\n", result);
219+
}
220+
221+
[Fact]
222+
public async Task ShouldParseTableRowWithRange()
223+
{
224+
_parser.TryParse("{% tablerow i in (1..4) cols:2 %}{{ i }}{% endtablerow %}", out var template, out var error);
225+
226+
Assert.Null(error);
227+
Assert.NotNull(template);
228+
229+
var result = await template.RenderAsync();
230+
231+
Assert.Equal("<tr class=\"row1\">\n<td class=\"col1\">1</td><td class=\"col2\">2</td></tr>\n<tr class=\"row2\"><td class=\"col1\">3</td><td class=\"col2\">4</td></tr>\n", result);
232+
}
233+
234+
[Fact]
235+
public async Task ShouldParseTableRowWithAllParameters()
236+
{
237+
_parser.TryParse("{% tablerow i in (1..10) cols:2 limit:4 offset:2 %}{{ i }}{% endtablerow %}", out var template, out var error);
238+
239+
Assert.Null(error);
240+
Assert.NotNull(template);
241+
242+
var result = await template.RenderAsync();
243+
244+
Assert.Equal("<tr class=\"row1\">\n<td class=\"col1\">3</td><td class=\"col2\">4</td></tr>\n<tr class=\"row2\"><td class=\"col1\">5</td><td class=\"col2\">6</td></tr>\n", result);
245+
}
246+
247+
[Fact]
248+
public async Task ColsShouldTruncateFloatToInt()
249+
{
250+
_parser.TryParse("{% tablerow i in (1..4) cols:2.6 %}{{ i }}{% endtablerow %}", out var template, out var error);
251+
252+
Assert.Null(error);
253+
Assert.NotNull(template);
254+
255+
var result = await template.RenderAsync();
256+
257+
// 2.6 truncates to 2
258+
Assert.Equal("<tr class=\"row1\">\n<td class=\"col1\">1</td><td class=\"col2\">2</td></tr>\n<tr class=\"row2\"><td class=\"col1\">3</td><td class=\"col2\">4</td></tr>\n", result);
259+
}
260+
261+
[Fact]
262+
public async Task ShouldHandleOddNumberOfItemsWithCols()
263+
{
264+
_parser.TryParse("{% tablerow i in (1..5) cols:2 %}{{ i }}{% endtablerow %}", out var template, out var error);
265+
266+
Assert.Null(error);
267+
Assert.NotNull(template);
268+
269+
var result = await template.RenderAsync();
270+
271+
Assert.Equal("<tr class=\"row1\">\n<td class=\"col1\">1</td><td class=\"col2\">2</td></tr>\n<tr class=\"row2\"><td class=\"col1\">3</td><td class=\"col2\">4</td></tr>\n<tr class=\"row3\"><td class=\"col1\">5</td></tr>\n", result);
272+
}
273+
274+
[Fact]
275+
public async Task TableRowLoopColLastShouldBeCorrect()
276+
{
277+
_parser.TryParse("{% tablerow i in (1..4) cols:2 %}{{ tablerowloop.col_last }}{% endtablerow %}", out var template, out var error);
278+
279+
var result = await template.RenderAsync();
280+
281+
Assert.Equal("<tr class=\"row1\">\n<td class=\"col1\">false</td><td class=\"col2\">true</td></tr>\n<tr class=\"row2\"><td class=\"col1\">false</td><td class=\"col2\">true</td></tr>\n", result);
282+
}
283+
284+
[Fact]
285+
public async Task TableRowLoopColFirstShouldBeCorrect()
286+
{
287+
_parser.TryParse("{% tablerow i in (1..4) cols:2 %}{{ tablerowloop.col_first }}{% endtablerow %}", out var template, out var error);
288+
289+
var result = await template.RenderAsync();
290+
291+
Assert.Equal("<tr class=\"row1\">\n<td class=\"col1\">true</td><td class=\"col2\">false</td></tr>\n<tr class=\"row2\"><td class=\"col1\">true</td><td class=\"col2\">false</td></tr>\n", result);
292+
}
293+
294+
[Fact]
295+
public async Task TableRowLoopRowShouldIncrement()
296+
{
297+
_parser.TryParse("{% tablerow i in (1..4) cols:2 %}{{ tablerowloop.row }}{% endtablerow %}", out var template, out var error);
298+
299+
var result = await template.RenderAsync();
300+
301+
Assert.Equal("<tr class=\"row1\">\n<td class=\"col1\">1</td><td class=\"col2\">1</td></tr>\n<tr class=\"row2\"><td class=\"col1\">2</td><td class=\"col2\">2</td></tr>\n", result);
302+
}
303+
}
304+
}

Fluid/Ast/AstRewriter.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -424,6 +424,20 @@ protected internal override Statement VisitForStatement(ForStatement forStatemen
424424
return forStatement;
425425
}
426426

427+
protected internal override Statement VisitTableRowStatement(TableRowStatement tableRowStatement)
428+
{
429+
if (TryRewriteExpression(tableRowStatement.Source, out var newSource) |
430+
TryRewriteExpression(tableRowStatement.Limit, out var newLimit) |
431+
TryRewriteExpression(tableRowStatement.Offset, out var newOffset) |
432+
TryRewriteExpression(tableRowStatement.Cols, out var newCols) |
433+
TryRewriteStatements(tableRowStatement.Statements, out var newStatements))
434+
{
435+
return new TableRowStatement(newStatements.ToList(), tableRowStatement.Identifier, newSource, newLimit, newOffset, newCols);
436+
}
437+
438+
return tableRowStatement;
439+
}
440+
427441
protected internal override Statement VisitFromStatement(FromStatement fromStatement)
428442
{
429443
if (TryRewriteExpression(fromStatement.Path, out var newPath))

Fluid/Ast/AstVisitor.cs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,21 @@ protected internal virtual Statement VisitForStatement(ForStatement forStatement
321321
return forStatement;
322322
}
323323

324+
protected internal virtual Statement VisitTableRowStatement(TableRowStatement tableRowStatement)
325+
{
326+
Visit(tableRowStatement.Source);
327+
Visit(tableRowStatement.Limit);
328+
Visit(tableRowStatement.Offset);
329+
Visit(tableRowStatement.Cols);
330+
331+
foreach (var statement in tableRowStatement.Statements)
332+
{
333+
Visit(statement);
334+
}
335+
336+
return tableRowStatement;
337+
}
338+
324339
protected internal virtual Statement VisitFromStatement(FromStatement fromStatement)
325340
{
326341
Visit(fromStatement.Path);

0 commit comments

Comments
 (0)