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’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Create format_number filter #999

Merged
merged 6 commits into from
Feb 6, 2023
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ protected void registerDefaults() {
Md5Filter.class,
MinusTimeFilter.class,
MultiplyFilter.class,
NumberFormatFilter.class,
PlusTimeFilter.class,
PrettyPrintFilter.class,
RandomFilter.class,
Expand Down
110 changes: 110 additions & 0 deletions src/main/java/com/hubspot/jinjava/lib/filter/NumberFormatFilter.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
package com.hubspot.jinjava.lib.filter;

import com.google.common.base.Strings;
import com.google.common.collect.ImmutableMap;
import com.hubspot.jinjava.doc.annotations.JinjavaDoc;
import com.hubspot.jinjava.doc.annotations.JinjavaParam;
import com.hubspot.jinjava.doc.annotations.JinjavaSnippet;
import com.hubspot.jinjava.interpret.JinjavaInterpreter;
import com.hubspot.jinjava.interpret.TemplateError;
import com.hubspot.jinjava.interpret.TemplateError.ErrorItem;
import com.hubspot.jinjava.interpret.TemplateError.ErrorReason;
import com.hubspot.jinjava.interpret.TemplateError.ErrorType;
import java.math.BigDecimal;
import java.text.DecimalFormat;
import java.text.NumberFormat;
import java.util.Locale;
import java.util.Objects;

@JinjavaDoc(
value = "Formats a given number based on the locale passed in as a parameter.",
input = @JinjavaParam(
value = "value",
desc = "The number to be formatted based on locale",
required = true
),
params = {
@JinjavaParam(
value = "locale",
desc = "Locale in which to format the number. The default is the page's locale."
),
@JinjavaParam(
value = "decimal precision number",
type = "number",
desc = "A number input that determines the decimal precision of the formatted value. If the number of decimal digits from the input value is less than the decimal precision number, use the number of decimal digits from the input value. Otherwise, use the decimal precision number. The default is the number of decimal digits from the input value."
)
julia-uy marked this conversation as resolved.
Show resolved Hide resolved
},
snippets = {
@JinjavaSnippet(code = "{{ number|format_number }}"),
@JinjavaSnippet(code = "{{ number|format_number(\"en-US\") }}"),
@JinjavaSnippet(code = "{{ number|format_number(\"en-US\", 3) }}")
}
)
public class NumberFormatFilter implements Filter {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think FormatNumberFilter would align with how filters are named relative to their representation in Jinjava, no?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed 👍🏼

private static final String FORMAT_NUMBER_FILTER_NAME = "format_number";

@Override
public String getName() {
return FORMAT_NUMBER_FILTER_NAME;
}

@Override
public Object filter(Object var, JinjavaInterpreter interpreter, String... args) {
Locale locale = args.length > 0 && !Strings.isNullOrEmpty(args[0])
? Locale.forLanguageTag(args[0])
: interpreter.getConfig().getLocale();

BigDecimal number;
try {
number = parseInput(var);
} catch (Exception e) {
if (interpreter.getContext().isValidationMode()) {
return "";
}
interpreter.addError(
new TemplateError(
ErrorType.WARNING,
ErrorReason.INVALID_INPUT,
ErrorItem.FILTER,
"Input value '" + var + "' could not be parsed.",
null,
interpreter.getLineNumber(),
e,
null,
ImmutableMap.of("value", Objects.toString(var))
)
);
return var;
}

int noOfDecimalPlacesInInput = Math.max(0, number.scale());
julia-uy marked this conversation as resolved.
Show resolved Hide resolved
int decimalPrecisionNumber = args.length > 1
? Integer.parseInt(args[1])
: noOfDecimalPlacesInInput;

return formatNumber(locale, number, noOfDecimalPlacesInInput, decimalPrecisionNumber);
}

private BigDecimal parseInput(Object input) throws Exception {
DecimalFormat df = (DecimalFormat) NumberFormat.getInstance();
df.setParseBigDecimal(true);

return (BigDecimal) df.parseObject(Objects.toString(input));
}

private String formatNumber(
Locale locale,
BigDecimal number,
int noOfDecimalPlacesInInput,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this parameter's value comes from number, I think we can get it inside this method instead of passing it as a separate parameter.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense! In the main method, I was using this variable as a fallback to the max decimal precision variable if it wasn't passed in, but I ended up switching the max decimal precision variable to an Optional so it no longer needs to be a parameter for this method

int decimalPrecisionNumber
) {
NumberFormat numberFormat = NumberFormat.getNumberInstance(locale);

numberFormat.setMinimumFractionDigits(noOfDecimalPlacesInInput);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you need to set this, actually? I'd think the default NumberFormat would try to show the entire passed-in value by default, which would mean it would use all decimal digits (unless it was over the maximum).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I double checked with the unit tests I wrote and I think you are correct. This doesn't need to be set since by default - it'll just output all the decimal digits unless a maximum less than the number of decimal digits was set

numberFormat.setMaximumFractionDigits(
Math.min(noOfDecimalPlacesInInput, decimalPrecisionNumber)
);

return numberFormat.format(number);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package com.hubspot.jinjava.lib.filter;

import static org.assertj.core.api.Assertions.assertThat;

import com.hubspot.jinjava.BaseJinjavaTest;
import java.util.HashMap;
import org.junit.Before;
import org.junit.Test;

public class NumberFormatFilterTest extends BaseJinjavaTest {

@Before
public void setup() {}

@Test
public void testNumberFormatFilter() {
assertThat(
jinjava.render("{{1000|format_number('en-US')}}", new HashMap<String, Object>())
)
.isEqualTo("1,000");
assertThat(
jinjava.render(
"{{ 1000.333|format_number('en-US') }}",
new HashMap<String, Object>()
)
)
.isEqualTo("1,000.333");
assertThat(
jinjava.render(
"{{ 1000.333|format_number('en-US', 2) }}",
new HashMap<String, Object>()
)
)
.isEqualTo("1,000.33");

assertThat(
jinjava.render("{{ 1000|format_number('fr') }}", new HashMap<String, Object>())
)
.isEqualTo("1\u00a0000");
assertThat(
jinjava.render(
"{{ 1000.333|format_number('fr') }}",
new HashMap<String, Object>()
)
)
.isEqualTo("1\u00a0000,333");
assertThat(
jinjava.render(
"{{ 1000.333|format_number('fr', 2) }}",
new HashMap<String, Object>()
)
)
.isEqualTo("1\u00a0000,33");

assertThat(
jinjava.render("{{ 1000|format_number('es') }}", new HashMap<String, Object>())
)
.isEqualTo("1.000");
assertThat(
jinjava.render(
"{{ 1000.333|format_number('es') }}",
new HashMap<String, Object>()
)
)
.isEqualTo("1.000,333");
assertThat(
jinjava.render(
"{{ 1000.333|format_number('es', 2) }}",
new HashMap<String, Object>()
)
)
.isEqualTo("1.000,33");
}
}