Skip to content

Commit 8f72fad

Browse files
authored
Merge pull request #20 from ministryofjustice/story/ccmspui-424-create-reusable-component
CCMSPUI-424 | Add govuk-dialect spring boot starter
2 parents 38f9ff5 + 444b7ec commit 8f72fad

File tree

9 files changed

+265
-0
lines changed

9 files changed

+265
-0
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
plugins {
2+
id 'spring-boot-starter-conventions'
3+
id 'checkstyle'
4+
}
5+
6+
dependencies {
7+
api("org.thymeleaf:thymeleaf-spring6")
8+
}
9+
10+
checkstyle {
11+
maxWarnings = 0
12+
toolVersion = checkstyleVersion
13+
sourceSets = sourceSets.main as SourceSetContainer
14+
showViolations = true
15+
}
16+
17+
publishing.publications {
18+
library(MavenPublication) {
19+
from components.java
20+
}
21+
}
22+
23+
test {
24+
useJUnitPlatform()
25+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
package uk.gov.laa.ccms.springboot.dialect;
2+
3+
import java.util.HashMap;
4+
import java.util.Map;
5+
import java.util.regex.Pattern;
6+
import org.apache.logging.log4j.util.Strings;
7+
import org.thymeleaf.context.ITemplateContext;
8+
import org.thymeleaf.model.IModel;
9+
import org.thymeleaf.model.IModelFactory;
10+
import org.thymeleaf.model.IProcessableElementTag;
11+
import org.thymeleaf.processor.element.AbstractElementTagProcessor;
12+
import org.thymeleaf.processor.element.IElementTagStructureHandler;
13+
import org.thymeleaf.standard.expression.IStandardExpression;
14+
import org.thymeleaf.standard.expression.IStandardExpressionParser;
15+
import org.thymeleaf.standard.expression.StandardExpressions;
16+
import org.thymeleaf.templatemode.TemplateMode;
17+
18+
/**
19+
* Transforms <govuk:button/> elements into standard HTML button elements.
20+
*/
21+
public class ButtonElementTagProcessor extends AbstractElementTagProcessor {
22+
23+
private static final String TAG_NAME = "button";
24+
private static final int PRECEDENCE = 900;
25+
private static final Pattern CLEANER = Pattern.compile("(?m)^[ \t]*\r?\n");
26+
27+
public ButtonElementTagProcessor() {
28+
super(TemplateMode.HTML, "govuk", TAG_NAME, true, null, false, PRECEDENCE);
29+
}
30+
31+
@Override
32+
protected void doProcess(ITemplateContext context, IProcessableElementTag tag,
33+
IElementTagStructureHandler structureHandler) {
34+
35+
Map<String, String> attributes = parseAttributes(context, tag);
36+
String classNames = buildClassNames(attributes);
37+
String commonAttributes = buildCommonAttributes(classNames, attributes);
38+
String buttonAttributes = buildButtonAttributes(attributes);
39+
40+
String html = attributes.containsKey("href") ? buildAnchorHtml(attributes, commonAttributes) :
41+
buildButtonHtml(attributes, commonAttributes, buttonAttributes);
42+
43+
replaceElementWithHtml(context, structureHandler, html);
44+
}
45+
46+
private Map<String, String> parseAttributes(ITemplateContext context,
47+
IProcessableElementTag tag) {
48+
Map<String, String> attributes = tag.getAttributeMap();
49+
Map<String, String> resolvedAttributes = new HashMap<>();
50+
IStandardExpressionParser parser =
51+
StandardExpressions.getExpressionParser(context.getConfiguration());
52+
53+
for (Map.Entry<String, String> entry : attributes.entrySet()) {
54+
String key = entry.getKey();
55+
String value = entry.getValue();
56+
if (key.startsWith("th:")) {
57+
IStandardExpression expression = parser.parseExpression(context, value);
58+
resolvedAttributes.put(key.replace("th:", ""), (String) expression.execute(context));
59+
} else {
60+
resolvedAttributes.put(key, value);
61+
}
62+
}
63+
64+
return resolvedAttributes;
65+
}
66+
67+
private String buildClassNames(Map<String, String> attributes) {
68+
String classNames = "govuk-button";
69+
if (attributes.containsKey("classes")) {
70+
classNames += " " + attributes.get("classes");
71+
}
72+
return classNames;
73+
}
74+
75+
private String buildCommonAttributes(String classNames, Map<String, String> attributes) {
76+
StringBuilder commonAttributes = new StringBuilder();
77+
commonAttributes.append("class=\"").append(classNames)
78+
.append("\" data-module=\"govuk-button\"");
79+
if (attributes.containsKey("id")) {
80+
commonAttributes.append(" id=\"").append(attributes.get("id")).append("\"");
81+
}
82+
return commonAttributes.toString();
83+
}
84+
85+
private String buildButtonAttributes(Map<String, String> attributes) {
86+
StringBuilder buttonAttributes = new StringBuilder();
87+
if (attributes.containsKey("name")) {
88+
buttonAttributes.append(" name=\"").append(attributes.get("name")).append("\"");
89+
}
90+
if (attributes.containsKey("disabled")) {
91+
buttonAttributes.append(" disabled aria-disabled=\"true\"");
92+
}
93+
if (attributes.containsKey("preventDoubleClick")) {
94+
buttonAttributes.append(" data-prevent-double-click=\"")
95+
.append(attributes.get("preventDoubleClick")).append("\"");
96+
}
97+
return buttonAttributes.toString();
98+
}
99+
100+
private String buildAnchorHtml(Map<String, String> attributes, String commonAttributes) {
101+
return "<a href=\"" + attributes.getOrDefault("href", "#")
102+
+ "\" role=\"button\" draggable=\"false\" " + commonAttributes + ">"
103+
+ attributes.getOrDefault("text", "") + "</a>";
104+
}
105+
106+
private String buildButtonHtml(Map<String, String> attributes, String commonAttributes,
107+
String buttonAttributes) {
108+
return "<button type=\"" + attributes.getOrDefault("type", "submit") + "\" "
109+
+ buttonAttributes + " " + commonAttributes + ">" + attributes.getOrDefault("text", "")
110+
+ "</button>";
111+
}
112+
113+
private void replaceElementWithHtml(ITemplateContext context,
114+
IElementTagStructureHandler structureHandler, String html) {
115+
final String adjusted = CLEANER.matcher(html).replaceAll(Strings.EMPTY);
116+
final IModelFactory modelFactory = context.getModelFactory();
117+
final IModel model = modelFactory.parse(context.getTemplateData(), adjusted);
118+
structureHandler.replaceWith(model, false);
119+
}
120+
}
121+
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package uk.gov.laa.ccms.springboot.dialect;
2+
3+
import java.util.Set;
4+
import org.thymeleaf.dialect.AbstractProcessorDialect;
5+
import org.thymeleaf.processor.IProcessor;
6+
import org.thymeleaf.standard.StandardDialect;
7+
8+
9+
/**
10+
* Develops a custom GOV.UK dialect.
11+
*/
12+
public class GovUkDialect extends AbstractProcessorDialect {
13+
14+
private static final String DIALECT_NAME = "GovUKDialect";
15+
16+
public GovUkDialect() {
17+
super(DIALECT_NAME, "govuk", StandardDialect.PROCESSOR_PRECEDENCE);
18+
}
19+
20+
@Override
21+
public Set<IProcessor> getProcessors(String dialectPrefix) {
22+
return Set.of(new ButtonElementTagProcessor());
23+
}
24+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package uk.gov.laa.ccms.springboot.dialect;
2+
3+
import org.springframework.boot.autoconfigure.AutoConfiguration;
4+
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
5+
import org.springframework.context.annotation.Bean;
6+
import org.thymeleaf.spring6.SpringTemplateEngine;
7+
8+
/**
9+
* Custom ThymeleafDialectConfig.
10+
*/
11+
@AutoConfiguration
12+
@ConditionalOnClass(SpringTemplateEngine.class)
13+
public class GovUkThymeleafDialectConfig {
14+
/**
15+
* SpringTemplateEngine with custom dialect.
16+
*/
17+
@Bean
18+
public GovUkDialect govUkDialect() {
19+
return new GovUkDialect();
20+
}
21+
22+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
uk.gov.laa.ccms.springboot.dialect.GovUkThymeleafDialectConfig
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package uk.gov.laa.ccms.springboot.dialect;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
5+
import org.junit.jupiter.api.Test;
6+
import org.springframework.beans.factory.annotation.Autowired;
7+
import org.springframework.boot.test.context.SpringBootTest;
8+
import org.thymeleaf.context.Context;
9+
import org.thymeleaf.spring6.SpringTemplateEngine;
10+
11+
@SpringBootTest(classes = ThymeleafTestConfig.class)
12+
class ButtonElementTagProcessorTest {
13+
14+
@Autowired
15+
private SpringTemplateEngine templateEngine;
16+
17+
@Test
18+
void shouldRenderGovukButton() {
19+
20+
Context context = new Context();
21+
String renderedHtml = templateEngine.process("test-button", context);
22+
assertThat(renderedHtml)
23+
.contains("<a href=\"/test\" role=\"button\" draggable=\"false\" class=\"govuk-button custom-class\" data-module=\"govuk-button\" id=\"button-id\">Click Me!</a>")
24+
.contains("<button type=\"submit\" disabled aria-disabled=\"true\" class=\"govuk-button\" data-module=\"govuk-button\" id=\"button-id\">Click Me!</button>");
25+
26+
}
27+
28+
29+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package uk.gov.laa.ccms.springboot.dialect;
2+
3+
import org.springframework.context.annotation.Bean;
4+
import org.springframework.context.annotation.Configuration;
5+
import org.thymeleaf.spring6.SpringTemplateEngine;
6+
import org.thymeleaf.spring6.templateresolver.SpringResourceTemplateResolver;
7+
import org.thymeleaf.templatemode.TemplateMode;
8+
9+
@Configuration
10+
public class ThymeleafTestConfig {
11+
12+
@Bean
13+
public SpringTemplateEngine testTemplateEngine() {
14+
SpringTemplateEngine templateEngine = new SpringTemplateEngine();
15+
templateEngine.addDialect(new GovUkDialect());
16+
templateEngine.addTemplateResolver(templateResolver());
17+
return templateEngine;
18+
}
19+
20+
@Bean
21+
public SpringResourceTemplateResolver templateResolver() {
22+
SpringResourceTemplateResolver resolver = new SpringResourceTemplateResolver();
23+
resolver.setPrefix("classpath:/templates/");
24+
resolver.setSuffix(".html");
25+
resolver.setTemplateMode(TemplateMode.HTML);
26+
resolver.setCacheable(false);
27+
return resolver;
28+
}
29+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<!DOCTYPE html>
2+
<html xmlns:govuk="http://www.thymeleaf.org" lang="EN">
3+
<head>
4+
<title>Button Test</title>
5+
</head>
6+
<body>
7+
8+
<govuk:button th:text="'Click Me!'" href="/test" id="button-id" classes="custom-class"/>
9+
10+
<govuk:button th:text="'Click Me!'" id="button-id" disabled/>
11+
12+
</body>
13+
</html>

settings.gradle

+1
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@ include ':laa-ccms-spring-boot-gradle-plugin'
55
include ':laa-ccms-java-gradle-plugin'
66
include ':laa-ccms-spring-boot-starters'
77
include ':laa-ccms-spring-boot-starters:laa-ccms-spring-boot-starter-auth'
8+
include ':laa-ccms-spring-boot-starters:laa-ccms-spring-boot-starter-govuk-dialect'

0 commit comments

Comments
 (0)