Skip to content

Commit 44ff5c3

Browse files
authored
Add failed rows sample table to test case notifications (#24912)
* Load sampleData for TestCase entities when hydrating the ChangeEvent entity. * Implement failedRowsSample table rendering across notification channels * Improve table style in failedRowsSample notification * Improve table style in Slack * Improve table style on GChat * Improve table style on MS Teams & refactor TeamsCardAssembler * Enforce text limits from Handlebars template * Fix failing tests * Move table rendering to Handlebars custom helper
1 parent 8986523 commit 44ff5c3

20 files changed

Lines changed: 1424 additions & 365 deletions

File tree

openmetadata-service/pom.xml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1090,6 +1090,11 @@
10901090
<artifactId>commonmark-ext-autolink</artifactId>
10911091
<version>${commonmark.version}</version>
10921092
</dependency>
1093+
<dependency>
1094+
<groupId>org.commonmark</groupId>
1095+
<artifactId>commonmark-ext-gfm-tables</artifactId>
1096+
<version>${commonmark.version}</version>
1097+
</dependency>
10931098
<!-- Flexmark HTML to Markdown Converter -->
10941099
<dependency>
10951100
<groupId>com.vladsch.flexmark</groupId>

openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/changeEvent/msteams/TeamsMessage.java

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,9 @@ public static class TextBlock implements TeamsMessage.BodyItem {
127127

128128
@JsonProperty("fontType")
129129
private String fontType;
130+
131+
@JsonProperty("isSubtle")
132+
private Boolean isSubtle;
130133
}
131134

132135
@Getter
@@ -143,6 +146,12 @@ public static class Container implements TeamsMessage.BodyItem {
143146

144147
@JsonProperty("items")
145148
private List<TeamsMessage.BodyItem> items;
149+
150+
@JsonProperty("bleed")
151+
private Boolean bleed;
152+
153+
@JsonProperty("spacing")
154+
private String spacing;
146155
}
147156

148157
@Getter
@@ -171,6 +180,79 @@ public static class Fact {
171180
private String value;
172181
}
173182

183+
@Getter
184+
@Setter
185+
@AllArgsConstructor
186+
@NoArgsConstructor
187+
@Builder
188+
public static class Table implements TeamsMessage.BodyItem {
189+
@JsonProperty("type")
190+
private String type;
191+
192+
@JsonProperty("gridStyle")
193+
private String gridStyle;
194+
195+
@JsonProperty("firstRowAsHeader")
196+
private Boolean firstRowAsHeader;
197+
198+
@JsonProperty("columns")
199+
private List<TeamsMessage.TableColumnDefinition> columns;
200+
201+
@JsonProperty("rows")
202+
private List<TeamsMessage.TableRow> rows;
203+
204+
@JsonProperty("spacing")
205+
private String spacing;
206+
207+
@JsonProperty("showGridLines")
208+
private Boolean showGridLines;
209+
210+
@JsonProperty("separator")
211+
private Boolean separator;
212+
213+
@JsonProperty("horizontalCellContentAlignment")
214+
private String horizontalCellContentAlignment;
215+
}
216+
217+
@Getter
218+
@Setter
219+
@AllArgsConstructor
220+
@NoArgsConstructor
221+
@Builder
222+
public static class TableColumnDefinition {
223+
@JsonProperty("width")
224+
private Integer width;
225+
}
226+
227+
@Getter
228+
@Setter
229+
@AllArgsConstructor
230+
@NoArgsConstructor
231+
@Builder
232+
public static class TableRow {
233+
@JsonProperty("type")
234+
private String type;
235+
236+
@JsonProperty("cells")
237+
private List<TeamsMessage.TableCell> cells;
238+
239+
@JsonProperty("style")
240+
private String style;
241+
}
242+
243+
@Getter
244+
@Setter
245+
@AllArgsConstructor
246+
@NoArgsConstructor
247+
@Builder
248+
public static class TableCell {
249+
@JsonProperty("type")
250+
private String type;
251+
252+
@JsonProperty("items")
253+
private List<TeamsMessage.BodyItem> items;
254+
}
255+
174256
// Interface for Body Items
175257
public interface BodyItem {}
176258
}

openmetadata-service/src/main/java/org/openmetadata/service/formatter/util/FormatterUtil.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
import org.openmetadata.schema.type.EventType;
4848
import org.openmetadata.schema.type.FieldChange;
4949
import org.openmetadata.schema.type.Include;
50+
import org.openmetadata.schema.type.TableData;
5051
import org.openmetadata.schema.utils.JsonUtils;
5152
import org.openmetadata.service.Entity;
5253
import org.openmetadata.service.formatter.decorators.MessageDecorator;
@@ -332,6 +333,13 @@ private static ChangeEvent getChangeEventForEntityTimeSeries(
332333
(TestCaseRepository) Entity.getEntityRepository(TEST_CASE);
333334
testCaseRepository.setInheritedFields(
334335
testCase, new EntityUtil.Fields(testCaseRepository.getAllowedFields()));
336+
// Load failedRowsSample
337+
try {
338+
TableData failedRowsSample = testCaseRepository.getSampleData(testCase, false);
339+
testCase.setFailedRowsSample(failedRowsSample);
340+
} catch (Exception e) {
341+
LOG.info("Failed to load failedRowsSample: {}", e.getMessage());
342+
}
335343
ChangeEvent changeEvent =
336344
getChangeEvent(
337345
updateBy,

openmetadata-service/src/main/java/org/openmetadata/service/notifications/channels/HtmlSanitizer.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,16 @@ public final class HtmlSanitizer {
1010
private static final PolicyFactory EMAIL_POLICY =
1111
new HtmlPolicyBuilder()
1212
.allowElements("p", "br", "div", "span")
13+
.allowAttributes("style", "class")
14+
.onElements("div", "pre", "code")
1315
.allowElements("ul", "ol", "li")
1416
.allowElements("strong", "b", "em", "i", "u", "s", "del")
1517
.allowElements("pre", "code")
1618
.allowElements("blockquote")
1719
.allowElements("h1", "h2", "h3", "h4", "h5", "h6")
1820
.allowElements("table", "thead", "tbody", "tr", "td", "th")
21+
.allowAttributes("class")
22+
.onElements("table", "thead", "tbody", "tr", "td", "th")
1923
.allowElements("a")
2024
.allowAttributes("href", "title")
2125
.onElements("a")

openmetadata-service/src/main/java/org/openmetadata/service/notifications/channels/HtmlToMarkdownAdapter.java

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,15 @@
88
import com.vladsch.flexmark.html2md.converter.HtmlNodeRendererHandler;
99
import com.vladsch.flexmark.util.data.DataHolder;
1010
import com.vladsch.flexmark.util.data.MutableDataSet;
11+
import java.util.ArrayList;
1112
import java.util.Arrays;
1213
import java.util.HashSet;
14+
import java.util.List;
1315
import java.util.Set;
1416
import lombok.extern.slf4j.Slf4j;
1517
import org.jetbrains.annotations.NotNull;
1618
import org.jsoup.nodes.Element;
19+
import org.jsoup.select.Elements;
1720

1821
@Slf4j
1922
public class HtmlToMarkdownAdapter implements TemplateFormatAdapter {
@@ -22,6 +25,7 @@ public class HtmlToMarkdownAdapter implements TemplateFormatAdapter {
2225
FlexmarkHtmlConverter.builder(new MutableDataSet())
2326
.htmlNodeRendererFactory(new CodeAndPreRenderer.Factory())
2427
.htmlNodeRendererFactory(new InlineElementRenderer.Factory())
28+
.htmlNodeRendererFactory(new TableRenderer.Factory())
2529
.build();
2630
private static final HtmlToMarkdownAdapter INSTANCE = new HtmlToMarkdownAdapter();
2731

@@ -185,4 +189,159 @@ public HtmlNodeRenderer apply(DataHolder options) {
185189
}
186190
}
187191
}
192+
193+
/** Handles HTML tables and converts them to GFM-style Markdown tables. */
194+
static class TableRenderer implements HtmlNodeRenderer {
195+
private static final int MAX_COL_WIDTH = 50;
196+
197+
TableRenderer(DataHolder options) {}
198+
199+
@Override
200+
public @NotNull Set<HtmlNodeRendererHandler<?>> getHtmlNodeRendererHandlers() {
201+
return new HashSet<>(
202+
Arrays.asList(
203+
new HtmlNodeRendererHandler<>("div", Element.class, this::renderDiv),
204+
new HtmlNodeRendererHandler<>("table", Element.class, this::renderTable),
205+
new HtmlNodeRendererHandler<>("thead", Element.class, this::skipElement),
206+
new HtmlNodeRendererHandler<>("tbody", Element.class, this::skipElement),
207+
new HtmlNodeRendererHandler<>("tr", Element.class, this::skipElement),
208+
new HtmlNodeRendererHandler<>("th", Element.class, this::skipElement),
209+
new HtmlNodeRendererHandler<>("td", Element.class, this::skipElement)));
210+
}
211+
212+
private void skipElement(Element elem, HtmlNodeConverterContext ctx, HtmlMarkdownWriter out) {
213+
// Skip - handled by table renderer
214+
}
215+
216+
/** Handles div elements, extracting and rendering any nested tables as GFM markdown. */
217+
private void renderDiv(Element div, HtmlNodeConverterContext ctx, HtmlMarkdownWriter out) {
218+
Element nestedTable = div.selectFirst("table");
219+
if (nestedTable != null) {
220+
renderTable(nestedTable, ctx, out);
221+
} else {
222+
ctx.renderChildren(div, false, null);
223+
}
224+
}
225+
226+
private void renderTable(Element table, HtmlNodeConverterContext ctx, HtmlMarkdownWriter out) {
227+
List<List<String>> headerRows = new ArrayList<>();
228+
List<List<String>> bodyRows = new ArrayList<>();
229+
230+
// Extract header rows from thead
231+
Element thead = table.selectFirst("thead");
232+
if (thead != null) {
233+
for (Element tr : thead.select("> tr")) {
234+
List<String> row = extractRowCells(tr, "th", "td");
235+
if (!row.isEmpty()) headerRows.add(row);
236+
}
237+
}
238+
239+
// Extract body rows from tbody or directly from table
240+
Element tbody = table.selectFirst("tbody");
241+
Elements bodyTrs = tbody != null ? tbody.select("> tr") : table.select("> tr");
242+
for (Element tr : bodyTrs) {
243+
// Skip rows that are inside thead
244+
if (tr.parent() != null && "thead".equalsIgnoreCase(tr.parent().tagName())) continue;
245+
List<String> row = extractRowCells(tr, "td", "th");
246+
if (!row.isEmpty()) bodyRows.add(row);
247+
}
248+
249+
// If no explicit header, use first body row as header
250+
if (headerRows.isEmpty() && !bodyRows.isEmpty()) {
251+
// Check if first row might be headers (all th cells)
252+
Element firstTr =
253+
tbody != null ? tbody.selectFirst("> tr") : table.selectFirst("> tr:not(thead > tr)");
254+
if (firstTr != null && !firstTr.select("> th").isEmpty()) {
255+
headerRows.add(bodyRows.removeFirst());
256+
}
257+
}
258+
259+
// Determine column count
260+
int colCount = 0;
261+
for (List<String> row : headerRows) colCount = Math.max(colCount, row.size());
262+
for (List<String> row : bodyRows) colCount = Math.max(colCount, row.size());
263+
264+
if (colCount == 0) return;
265+
266+
// Calculate column widths
267+
int[] colWidths = new int[colCount];
268+
for (int i = 0; i < colCount; i++) colWidths[i] = 3; // minimum width
269+
270+
for (List<String> row : headerRows) {
271+
for (int i = 0; i < row.size(); i++) {
272+
colWidths[i] = Math.max(colWidths[i], Math.min(row.get(i).length(), MAX_COL_WIDTH));
273+
}
274+
}
275+
for (List<String> row : bodyRows) {
276+
for (int i = 0; i < row.size(); i++) {
277+
colWidths[i] = Math.max(colWidths[i], Math.min(row.get(i).length(), MAX_COL_WIDTH));
278+
}
279+
}
280+
281+
out.blankLine();
282+
283+
// Render header
284+
if (!headerRows.isEmpty()) {
285+
for (List<String> headerRow : headerRows) {
286+
renderRow(out, headerRow, colWidths, colCount);
287+
}
288+
} else {
289+
// Create generic headers
290+
List<String> genericHeaders = new ArrayList<>();
291+
for (int i = 0; i < colCount; i++) genericHeaders.add("Column " + (i + 1));
292+
renderRow(out, genericHeaders, colWidths, colCount);
293+
}
294+
295+
// Render separator
296+
out.append("|");
297+
for (int i = 0; i < colCount; i++) {
298+
out.append("-".repeat(colWidths[i] + 2)).append("|");
299+
}
300+
out.line();
301+
302+
// Render body
303+
for (List<String> row : bodyRows) {
304+
renderRow(out, row, colWidths, colCount);
305+
}
306+
307+
out.blankLine();
308+
}
309+
310+
private List<String> extractRowCells(Element tr, String primary, String secondary) {
311+
List<String> cells = new ArrayList<>();
312+
Elements primaryCells = tr.select("> " + primary);
313+
Elements secondaryCells = tr.select("> " + secondary);
314+
Elements allCells = primaryCells.isEmpty() ? secondaryCells : primaryCells;
315+
316+
for (Element cell : allCells) {
317+
String text = cell.text().replace("|", "\\|").replace("\n", " ").trim();
318+
cells.add(text);
319+
}
320+
return cells;
321+
}
322+
323+
private void renderRow(HtmlMarkdownWriter out, List<String> cells, int[] colWidths, int colCt) {
324+
out.append("|");
325+
for (int i = 0; i < colCt; i++) {
326+
String cell = i < cells.size() ? cells.get(i) : "";
327+
if (cell.length() > colWidths[i]) {
328+
cell = cell.substring(0, colWidths[i] - 3) + "...";
329+
}
330+
out.append(" ").append(padRight(cell, colWidths[i])).append(" |");
331+
}
332+
out.line();
333+
}
334+
335+
private String padRight(String s, int width) {
336+
if (s.length() >= width) return s;
337+
return s + " ".repeat(width - s.length());
338+
}
339+
340+
static class Factory implements HtmlNodeRendererFactory {
341+
@Override
342+
public HtmlNodeRenderer apply(DataHolder options) {
343+
return new TableRenderer(options);
344+
}
345+
}
346+
}
188347
}

openmetadata-service/src/main/java/org/openmetadata/service/notifications/channels/MarkdownParser.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import org.commonmark.Extension;
66
import org.commonmark.ext.autolink.AutolinkExtension;
77
import org.commonmark.ext.gfm.strikethrough.StrikethroughExtension;
8+
import org.commonmark.ext.gfm.tables.TablesExtension;
89
import org.commonmark.node.Node;
910
import org.commonmark.parser.Parser;
1011

@@ -18,7 +19,8 @@ private MarkdownParser() {}
1819

1920
static {
2021
List<Extension> extensions =
21-
List.of(AutolinkExtension.create(), StrikethroughExtension.create());
22+
List.of(
23+
AutolinkExtension.create(), StrikethroughExtension.create(), TablesExtension.create());
2224
PARSER = Parser.builder().extensions(extensions).build();
2325
}
2426

0 commit comments

Comments
 (0)