Skip to content

Commit 564b7f9

Browse files
authored
Merge pull request #1 from HellButcher/main
2 parents 40db7ee + c3fb0db commit 564b7f9

File tree

7 files changed

+183
-40
lines changed

7 files changed

+183
-40
lines changed

README.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ A dialect for Thymeleaf that enables reusable UI components with named slots and
1212
- Define reusable UI components
1313
- Support for default and named slots
1414
- Support for component parameters
15+
- Supports default values for component parameters
1516
- Simple integration with Thymeleaf into Spring
1617

1718
## Installation
@@ -57,9 +58,10 @@ When directly instantiating the template engine, set the component dialect using
5758

5859
```html
5960
<!-- src/main/resources/templates/components/card.html -->
60-
<th:block xmlns:th="http://www.thymeleaf.org" th:fragment="card(title)">
61+
<th:block xmlns:th="http://www.thymeleaf.org" th:fragment="card(title)" pl:subtitle="${null}">
6162
<div class="card">
6263
<div class="card-header" th:text="${title}">Card Title</div>
64+
<div th:if="${subtitle}" class="card-subtitle" th:text="${subtitle}">Card Subtitle</div>
6365
<div class="card-body">
6466
<pl:slot>
6567
<!-- default slot -->
@@ -86,9 +88,13 @@ Do the same accordingly for the other components.
8688
<b>This will replace the footer slot content</b>
8789
</div>
8890
</pl:card>
91+
92+
<pl:card pl:title="My Card" pl:subtitle="My Subtitle">
93+
<div>This will replace the default slot content</div>
94+
</pl:card>
8995
```
9096

9197
## License
9298

9399
Thymeleaf Component Dialect is Open Source software released under the
94-
[Apache 2.0 license](http://www.apache.org/licenses/LICENSE-2.0.html).
100+
[Apache 2.0 license](http://www.apache.org/licenses/LICENSE-2.0.html).

src/main/java/ch/cstettler/thymeleaf/ComponentDialect.java

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,20 +22,23 @@
2222

2323
public class ComponentDialect extends AbstractProcessorDialect {
2424

25-
private static final String DIALECT_PREFIX = "pl";
25+
private static final String DEFAULT_DIALECT_PREFIX = "pl";
2626

2727
private final Set<IProcessor> processors;
2828

2929
public ComponentDialect() {
30-
super("Thymeleaf UI Component Dialect", DIALECT_PREFIX, 0);
30+
this(DEFAULT_DIALECT_PREFIX);
31+
}
32+
33+
public ComponentDialect(String prefix) {
34+
super("Thymeleaf UI Component Dialect", prefix, 0);
3135

3236
this.processors = new HashSet<>();
33-
this.processors.add(new RemoveSlotAttributeProcessor(DIALECT_PREFIX, "slot"));
37+
this.processors.add(new RemoveSlotAttributeProcessor(prefix, "slot"));
3438
}
3539

3640
public ComponentDialect addComponent(String elementName, String templatePath) {
37-
processors.add(new ComponentModelProcessor(DIALECT_PREFIX, elementName, templatePath));
38-
41+
processors.add(new ComponentModelProcessor(getPrefix (), elementName, templatePath));
3942
return this;
4043
}
4144

src/main/java/ch/cstettler/thymeleaf/ComponentModelProcessor.java

Lines changed: 94 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,14 @@
2323
import static org.thymeleaf.templatemode.TemplateMode.HTML;
2424

2525
import java.util.ArrayList;
26+
import java.util.Collections;
2627
import java.util.HashMap;
2728
import java.util.List;
2829
import java.util.Map;
30+
import java.util.Set;
31+
import java.util.stream.IntStream;
32+
import java.util.stream.Stream;
33+
2934
import org.thymeleaf.context.ITemplateContext;
3035
import org.thymeleaf.engine.TemplateManager;
3136
import org.thymeleaf.engine.TemplateModel;
@@ -36,6 +41,7 @@
3641
import org.thymeleaf.model.IModelFactory;
3742
import org.thymeleaf.model.IOpenElementTag;
3843
import org.thymeleaf.model.IProcessableElementTag;
44+
import org.thymeleaf.model.IStandaloneElementTag;
3945
import org.thymeleaf.model.ITemplateEvent;
4046
import org.thymeleaf.processor.element.AbstractElementModelProcessor;
4147
import org.thymeleaf.processor.element.IElementModelStructureHandler;
@@ -55,7 +61,7 @@ public ComponentModelProcessor(String dialectPrefix, String elementName, String
5561

5662
this.dialectPrefix = dialectPrefix;
5763
this.elementName = elementName;
58-
this.templatePath = templatePath;
64+
this.templatePath = templatePath != null ? templatePath : dialectPrefix + "/" + elementName + "/" + elementName;
5965
}
6066

6167
@Override
@@ -77,9 +83,15 @@ protected void doProcess(ITemplateContext context, IModel model, IElementModelSt
7783
componentAttributes.forEach(structureHandler::setLocalVariable);
7884

7985
IModel fragmentModel = loadFragmentModel(context);
86+
IProcessableElementTag fragmentRootElementTag = firstOpenElementTagWithAttribute(fragmentModel, "th:fragment");
87+
if (fragmentRootElementTag != null) {
88+
Map<String, Object> defaultAttributes = resolveComponentAttributes (fragmentRootElementTag, context, expressionParser);
89+
componentAttributes.keySet ().forEach (defaultAttributes::remove);
90+
defaultAttributes.forEach (structureHandler::setLocalVariable);
91+
}
8092
Map<String, List<ITemplateEvent>> slotContents = extractSlotContents(model);
8193
Map<String, ITemplateEvent> slots = extractSlots(fragmentModel);
82-
IModel mergedModel = prepareModel(context, fragmentModel, additionalAttributes, slots, slotContents);
94+
IModel mergedModel = prepareModel(context, fragmentModel, fragmentRootElementTag, additionalAttributes, slots, slotContents);
8395

8496
model.reset();
8597
model.addModel(mergedModel);
@@ -90,7 +102,7 @@ private boolean isValidComponentTag(IProcessableElementTag componentElementTag)
90102
}
91103

92104
private IModel loadFragmentModel(ITemplateContext context) {
93-
return parseFragmentTemplateModel(context, templatePath != null ? templatePath : "pl/" + elementName + "/" + elementName);
105+
return parseFragmentTemplateModel(context, templatePath);
94106
}
95107

96108
private Map<String, List<ITemplateEvent>> extractSlotContents(IModel model) {
@@ -133,43 +145,96 @@ private Map<String, ITemplateEvent> extractSlots(IModel fragmentModel) {
133145
private IModel prepareModel(
134146
ITemplateContext context,
135147
IModel fragmentModel,
148+
IProcessableElementTag fragmentRootElementTag,
136149
Map<String, Object> additionalAttributes,
137150
Map<String, ITemplateEvent> slots,
138151
Map<String, List<ITemplateEvent>> slotContents
139152
) {
140153
IModelFactory modelFactory = context.getModelFactory();
141154
IModel newModel = modelFactory.createModel();
142155

143-
newModel.add(blockOpenElement(modelFactory, additionalAttributes));
156+
List<ITemplateEvent> fragmentElementTags = subTreeBelow(fragmentModel, fragmentRootElementTag);
157+
boolean hasPassedDownAttributes = replaceAdditionalAttributes(fragmentElementTags, modelFactory, additionalAttributes);
158+
if (!hasPassedDownAttributes) {
159+
newModel.add(blockOpenElement(modelFactory, additionalAttributes));
160+
}
161+
162+
fillSlots(fragmentModel, fragmentElementTags, slots, slotContents);
144163

145-
List<ITemplateEvent> mergedElementTags = fillSlots(fragmentModel, slots, slotContents);
146-
mergedElementTags.forEach(newModel::add);
164+
fragmentElementTags.forEach(newModel::add);
147165

148-
newModel.add(blockCloseElement(modelFactory));
166+
if (!hasPassedDownAttributes) {
167+
newModel.add (blockCloseElement (modelFactory));
168+
}
149169

150170
return newModel;
151171
}
152172

153-
private List<ITemplateEvent> fillSlots(
154-
IModel fragmentModel, Map<String, ITemplateEvent> slots,
173+
private static Map<String,String> convertObjectMapToStringMap(Map<String, Object> objectMap) {
174+
Map<String, String> stringMap = new HashMap<>(objectMap.size ());
175+
objectMap.forEach((key, value) -> stringMap.put(key, value != null ? value.toString() : null));
176+
return stringMap;
177+
}
178+
179+
private boolean replaceAdditionalAttributes(List<ITemplateEvent> fragmentElementTags, IModelFactory modelFactory, Map<String, Object> additionalAttributes) {
180+
boolean replaced = false;
181+
Set<String> removeAttributes = Collections.singleton (dialectPrefix + ":pass-additional-attributes");
182+
for (int i = 0; i < fragmentElementTags.size(); i++) {
183+
ITemplateEvent templateEvent = fragmentElementTags.get(i);
184+
if (templateEvent instanceof IProcessableElementTag elementTag
185+
&& elementTag.hasAttribute (dialectPrefix, "pass-additional-attributes")) {
186+
fragmentElementTags.set(i, copyTagWithModifiedAttributes (elementTag, modelFactory, additionalAttributes, removeAttributes));
187+
replaced = true;
188+
}
189+
}
190+
return replaced;
191+
}
192+
193+
private IProcessableElementTag copyTagWithModifiedAttributes (IProcessableElementTag elementTag, IModelFactory modelFactory, Map<String, Object> additionalAttributes, Set<String> removeAttributes) {
194+
Map<String,String> newAttributes = additionalAttributes == null ? new HashMap<> () : convertObjectMapToStringMap(additionalAttributes);
195+
newAttributes.putAll (elementTag.getAttributeMap ());
196+
if (removeAttributes != null) {
197+
removeAttributes.forEach (newAttributes::remove);
198+
}
199+
200+
if (elementTag instanceof IOpenElementTag) {
201+
return modelFactory.createOpenElementTag (
202+
elementTag.getElementCompleteName (),
203+
newAttributes,
204+
DOUBLE,
205+
false
206+
);
207+
} else if (elementTag instanceof IStandaloneElementTag standaloneElementTag) {
208+
return modelFactory.createStandaloneElementTag (
209+
elementTag.getElementCompleteName (),
210+
newAttributes,
211+
DOUBLE,
212+
false,
213+
standaloneElementTag.isMinimized ()
214+
);
215+
}
216+
throw new IllegalArgumentException ("Unsupported tag class");
217+
}
218+
219+
private void fillSlots(
220+
IModel fragmentModel,
221+
List<ITemplateEvent> fragmentElementTags,
222+
Map<String, ITemplateEvent> slots,
155223
Map<String, List<ITemplateEvent>> slotContents
156224
) {
157-
List<ITemplateEvent> fragmentElementTags = subTreeBelow(fragmentModel, firstOpenElementTagWithAttribute(fragmentModel, "th:fragment"));
158225
slots.forEach((slotName, slotElementTag) -> {
159226
List<ITemplateEvent> slotContent = slotContents.get(slotName);
160227

161228
if (slotContent == null || slotContent.isEmpty()) {
162-
if (slotElementTag instanceof IOpenElementTag) {
163-
slotContent = fallbackSlotContent(fragmentModel, (IOpenElementTag) slotElementTag);
229+
if (slotElementTag instanceof IOpenElementTag openElementTag) {
230+
slotContent = fallbackSlotContent(fragmentModel, openElementTag);
164231
} else {
165232
slotContent = emptyList();
166233
}
167234
}
168235

169236
fillSlot(fragmentElementTags, subTreeFrom(fragmentModel, slotElementTag), slotContent);
170237
});
171-
172-
return fragmentElementTags;
173238
}
174239

175240
private void fillSlot(List<ITemplateEvent> templateEvents, List<ITemplateEvent> slotSubTree, List<ITemplateEvent> slotContent) {
@@ -183,8 +248,7 @@ private static List<ITemplateEvent> fallbackSlotContent(IModel fragmentModel, IO
183248
}
184249

185250
private static IOpenElementTag blockOpenElement(IModelFactory modelFactory, Map<String, Object> attributes) {
186-
Map<String, String> attributesMap = new HashMap<>();
187-
attributes.forEach((key, value) -> attributesMap.put(key, value != null ? value.toString() : null));
251+
Map<String, String> attributesMap = convertObjectMapToStringMap (attributes);
188252

189253
return modelFactory.createOpenElementTag("th:block", attributesMap, DOUBLE, false);
190254
}
@@ -194,8 +258,8 @@ private static ICloseElementTag blockCloseElement(IModelFactory modelFactory) {
194258
}
195259

196260
private boolean isSlot(ITemplateEvent templateEvent) {
197-
if (templateEvent instanceof IProcessableElementTag) {
198-
return ((IProcessableElementTag) templateEvent).getElementCompleteName().equals(dialectPrefix + ":slot");
261+
if (templateEvent instanceof IProcessableElementTag elementTag) {
262+
return elementTag.getElementCompleteName().equals(dialectPrefix + ":slot");
199263
}
200264

201265
return false;
@@ -212,16 +276,16 @@ private String slotNameOf(IProcessableElementTag elementTag) {
212276
}
213277

214278
private static IProcessableElementTag firstOpenOrStandaloneElementTag(IModel model) {
215-
return templateEventsIn(model).stream()
216-
.filter((elementTag) -> elementTag instanceof IProcessableElementTag)
279+
return templateEventsIn(model)
280+
.filter(elementTag -> elementTag instanceof IProcessableElementTag)
217281
.map(templateEvent -> (IProcessableElementTag)templateEvent)
218282
.findFirst()
219283
.orElse(null);
220284
}
221285

222286
private static IProcessableElementTag firstOpenElementTagWithAttribute(IModel model, String attributeName) {
223-
return templateEventsIn(model).stream()
224-
.filter((elementTag) -> elementTag instanceof IOpenElementTag)
287+
return templateEventsIn(model)
288+
.filter(elementTag -> elementTag instanceof IOpenElementTag)
225289
.map(templateEvent -> (IProcessableElementTag)templateEvent)
226290
.filter(elementTag -> elementTag.hasAttribute(attributeName))
227291
.findFirst()
@@ -263,10 +327,14 @@ private Map<String, Object> resolveAdditionalAttributes(IProcessableElementTag e
263327

264328
private static Object tryResolveAttributeValue(IAttribute attribute, ITemplateContext context,
265329
IStandardExpressionParser expressionParser) {
330+
String value = attribute.getValue();
331+
if (value == null) {
332+
return null;
333+
}
266334
try {
267-
return expressionParser.parseExpression(context, attribute.getValue()).execute(context);
335+
return expressionParser.parseExpression(context, value).execute(context);
268336
} catch (TemplateProcessingException e) {
269-
return attribute.getValue();
337+
return value;
270338
}
271339
}
272340

@@ -319,13 +387,7 @@ static List<ITemplateEvent> subTreeFrom(IModel model, ITemplateEvent startTempla
319387
return subTree;
320388
}
321389

322-
private static List<ITemplateEvent> templateEventsIn(IModel model) {
323-
List<ITemplateEvent> templateEvents = new ArrayList<>();
324-
325-
for (int i = 0; i < model.size(); i++) {
326-
templateEvents.add(model.get(i));
327-
}
328-
329-
return templateEvents;
390+
private static Stream<ITemplateEvent> templateEventsIn(IModel model) {
391+
return IntStream.range(0, model.size()).mapToObj (model::get);
330392
}
331393
}

src/test/java/ch/cstettler/thymeleaf/ComponentModelProcessorTest.java

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,14 @@ void simple_additionalDynamicAttribute_rendersAttribute() {
8080
assertMarkupEquals("<i key='value'>simple</i>", html);
8181
}
8282

83+
@Test
84+
void withPassAdditionalAttributes_additionalDynamicAttribute_rendersAttribute() {
85+
String html = render("<pl:with-pass-additional-attributes th:attr='key=value' />");
86+
87+
assertMarkupEquals("<i key=\"value\">has-additional-attributes</i>" +
88+
"<b key=\"value\">also-has-additional-attributes</b>", html);
89+
}
90+
8391
@Test
8492
void simple_ifConditionTrue_renders() {
8593
String html = render("<pl:simple th:if='true' />");
@@ -119,6 +127,57 @@ void withParameter_parameterNotDefined_renders() {
119127
assertMarkupEquals("<i></i>", html);
120128
}
121129

130+
@Test
131+
void withVariable_variableDefinedAsParameter_rendersVariable() {
132+
String html = render("<pl:with-variable pl:variable='|with-variable-defined:${variable}|' />");
133+
134+
assertMarkupEquals("<i>with-variable-defined:null</i>", html);
135+
}
136+
137+
@Test
138+
void withVariable_variableDefinedAsByWith_rendersVariable() {
139+
String html = render("<pl:with-variable th:with='variable=|with-variable-defined:${variable}|' />");
140+
141+
assertMarkupEquals("<i>with-variable-defined:null</i>", html);
142+
}
143+
144+
@Test
145+
void withVariable_variableNotDefined_renders() {
146+
String html = render("<pl:with-variable />");
147+
148+
assertMarkupEquals("<i></i>", html);
149+
}
150+
151+
152+
@Test
153+
void withDefaultValue_variableDefinedAsParameter_rendersProvidedVariable() {
154+
String html = render("<pl:with-default-value pl:variable='|with-variable-defined:${variable}|' />");
155+
156+
assertMarkupEquals("<i>with-variable-defined:null</i>", html);
157+
}
158+
159+
160+
@Test
161+
void withDefaultValue_variableDefinedAsByWithOnParentTag_defaultValue() {
162+
String html = render("<th:block th:with='variable=|with-variable-defined:${variable}|'><pl:with-default-value /></th:block>");
163+
164+
assertMarkupEquals("<i>default-value</i>", html);
165+
}
166+
167+
@Test
168+
void withDefaultValue_variableDefinedAsByWithOnSameTag_rendersProvidedVariableWithDefaultValuePredefined() {
169+
String html = render("<pl:with-default-value th:with='variable=|with-variable-defined:${variable}|' />");
170+
171+
assertMarkupEquals("<i>with-variable-defined:default-value</i>", html);
172+
}
173+
174+
@Test
175+
void withDefaultValue_variableNotDefined_rendersDefaultValue() {
176+
String html = render("<pl:with-default-value />");
177+
178+
assertMarkupEquals("<i>default-value</i>", html);
179+
}
180+
122181
@Test
123182
void withDefaultSlot_slotContentDefined_rendersSlotContent() {
124183
String html = render(""
@@ -398,6 +457,9 @@ private static String render(String template) {
398457
ComponentDialect componentDialect = new ComponentDialect()
399458
.addComponent("simple", "components/simple.html")
400459
.addComponent("with-parameter", "components/with-parameter.html")
460+
.addComponent("with-variable", "components/with-variable.html")
461+
.addComponent("with-pass-additional-attributes", "components/with-pass-additional-attributes.html")
462+
.addComponent("with-default-value", "components/with-default-value.html")
401463
.addComponent("with-default-and-named-slots", "components/with-default-and-named-slots.html")
402464
.addComponent("with-default-slot", "components/with-default-slot.html")
403465
.addComponent("with-named-slots", "components/with-named-slots.html")
@@ -448,4 +510,4 @@ public TemplateResolution resolveTemplate(
448510
.orElseThrow(() -> new IllegalStateException("no template resolver found for template '" + template + "'"));
449511
}
450512
}
451-
}
513+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
<th:block xmlns:th="http://www.thymeleaf.org" th:fragment="with-variable()" pl:variable="default-value">
2+
<i th:text="${variable}"></i>
3+
</th:block>

0 commit comments

Comments
 (0)