diff --git a/app/assets/javascripts/behaviors/components/json_table.vue b/app/assets/javascripts/behaviors/components/json_table.vue
new file mode 100644
index 0000000000000000000000000000000000000000..bb38d80c1b54d3aa0dcde614aedea0a43999e719
--- /dev/null
+++ b/app/assets/javascripts/behaviors/components/json_table.vue
@@ -0,0 +1,71 @@
+
+
+
+
+
+
+ {{ caption }}
+
+
+
+
diff --git a/app/assets/javascripts/behaviors/markdown/render_gfm.js b/app/assets/javascripts/behaviors/markdown/render_gfm.js
index c9ae370638358bbe31d10643b76c03e86709d38b..ee5c0fe5ef350c89aed025660be5c5bdae3b2719 100644
--- a/app/assets/javascripts/behaviors/markdown/render_gfm.js
+++ b/app/assets/javascripts/behaviors/markdown/render_gfm.js
@@ -5,6 +5,7 @@ import { renderKroki } from './render_kroki';
import renderMath from './render_math';
import renderSandboxedMermaid from './render_sandboxed_mermaid';
import renderMetrics from './render_metrics';
+import { renderJSONTable } from './render_json_table';
// Render GitLab flavoured Markdown
//
@@ -15,6 +16,9 @@ $.fn.renderGFM = function renderGFM() {
renderKroki(this.find('.js-render-kroki[hidden]').get());
renderMath(this.find('.js-render-math'));
renderSandboxedMermaid(this.find('.js-render-mermaid'));
+ renderJSONTable(
+ Array.from(this.find('[lang="json"][data-lang-params="table"]').get()).map((e) => e.parentNode),
+ );
highlightCurrentUser(this.find('.gfm-project_member').get());
diff --git a/app/assets/javascripts/behaviors/markdown/render_json_table.js b/app/assets/javascripts/behaviors/markdown/render_json_table.js
new file mode 100644
index 0000000000000000000000000000000000000000..4d9ac1d266ba5080c7cd46209ec61e8d6a313747
--- /dev/null
+++ b/app/assets/javascripts/behaviors/markdown/render_json_table.js
@@ -0,0 +1,70 @@
+import { memoize } from 'lodash';
+import Vue from 'vue';
+import { __ } from '~/locale';
+import { createAlert } from '~/flash';
+
+// Async import component since we might not need it...
+const JSONTable = memoize(() =>
+ import(/* webpackChunkName: 'gfm_json_table' */ '../components/json_table.vue'),
+);
+
+const mountParseError = (element) => {
+ // Let the error container be a sibling to the element.
+ // Otherwise, dismissing the alert causes the copy button to be misplaced.
+ const container = document.createElement('div');
+ element.insertAdjacentElement('beforebegin', container);
+
+ // We need to create a child element with a known selector for `createAlert`
+ const el = document.createElement('div');
+ el.classList.add('js-json-table-error');
+
+ container.insertAdjacentElement('afterbegin', el);
+
+ return createAlert({
+ message: __('Unable to parse JSON'),
+ variant: 'warning',
+ parent: container,
+ containerSelector: '.js-json-table-error',
+ });
+};
+
+const mountJSONTableVueComponent = (userData, element) => {
+ const { fields = [], items = [], filter, caption } = userData;
+
+ const container = document.createElement('div');
+ element.innerHTML = '';
+ element.appendChild(container);
+
+ return new Vue({
+ el: container,
+ render(h) {
+ return h(JSONTable, {
+ props: {
+ fields,
+ items,
+ hasFilter: filter,
+ caption,
+ },
+ });
+ },
+ });
+};
+
+const renderTable = (element) => {
+ // Avoid rendering multiple times
+ if (!element || element.classList.contains('js-json-table')) {
+ return;
+ }
+
+ element.classList.add('js-json-table');
+
+ try {
+ mountJSONTableVueComponent(JSON.parse(element.textContent), element);
+ } catch (e) {
+ mountParseError(element);
+ }
+};
+
+export const renderJSONTable = (elements) => {
+ elements.forEach(renderTable);
+};
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index d07818c19f55e1210a518d6562824e0e4b7bf989..5c6ec47f78ea740e6f2db607670db4afc397c82c 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -16800,6 +16800,9 @@ msgstr ""
msgid "Generate site and private keys at"
msgstr ""
+msgid "Generated with JSON data"
+msgstr ""
+
msgid "Generic"
msgstr ""
@@ -41250,6 +41253,9 @@ msgstr ""
msgid "Type"
msgstr ""
+msgid "Type to search"
+msgstr ""
+
msgid "U2F Devices (%{length})"
msgstr ""
@@ -41370,6 +41376,9 @@ msgstr ""
msgid "Unable to load the merge request widget. Try reloading the page."
msgstr ""
+msgid "Unable to parse JSON"
+msgstr ""
+
msgid "Unable to parse the vulnerability report's options."
msgstr ""
diff --git a/spec/features/markdown/json_table_spec.rb b/spec/features/markdown/json_table_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..6b74dbac255bea249989b5a6e70afa58818c08f6
--- /dev/null
+++ b/spec/features/markdown/json_table_spec.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Rendering json:table code block in markdown', :js do
+ let_it_be(:project) { create(:project, :public) }
+
+ it 'creates table correctly' do
+ description = <<~JSONTABLE
+ Hello world!
+
+ ```json:table
+ {
+ "fields" : [
+ {"key": "a", "label": "AA"},
+ {"key": "b", "label": "BB"}
+ ],
+ "items" : [
+ {"a": "11", "b": "22"},
+ {"a": "211", "b": "222"}
+ ]
+ }
+ ```
+ JSONTABLE
+
+ issue = create(:issue, project: project, description: description)
+
+ visit project_issue_path(project, issue)
+
+ wait_for_requests
+
+ within ".js-json-table table" do
+ headers = all("thead th").collect { |column| column.text.strip }
+ data = all("tbody td").collect { |column| column.text.strip }
+
+ expect(headers).to eql(%w[AA BB])
+ expect(data).to eql(%w[11 22 211 222])
+ end
+ end
+end
diff --git a/spec/frontend/__helpers__/stub_component.js b/spec/frontend/__helpers__/stub_component.js
index 96fe3a8bc451229512981edede37a35c35ca5b37..4f9d1ee6f5dc7c3e51a8ec003721189a580477aa 100644
--- a/spec/frontend/__helpers__/stub_component.js
+++ b/spec/frontend/__helpers__/stub_component.js
@@ -22,6 +22,14 @@ const createStubbedMethods = (methods = {}) => {
);
};
+export const RENDER_ALL_SLOTS_TEMPLATE = `
`;
+
export function stubComponent(Component, options = {}) {
return {
props: Component.props,
diff --git a/spec/frontend/behaviors/components/json_table_spec.js b/spec/frontend/behaviors/components/json_table_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..42b4a051d4d16f4a7c6262d4d013cb8b76a70d1d
--- /dev/null
+++ b/spec/frontend/behaviors/components/json_table_spec.js
@@ -0,0 +1,162 @@
+import { GlTable, GlFormInput } from '@gitlab/ui';
+import { nextTick } from 'vue';
+import { merge } from 'lodash';
+import { shallowMountExtended, mountExtended } from 'helpers/vue_test_utils_helper';
+import { stubComponent, RENDER_ALL_SLOTS_TEMPLATE } from 'helpers/stub_component';
+import JSONTable from '~/behaviors/components/json_table.vue';
+
+const TEST_FIELDS = [
+ 'A',
+ {
+ key: 'B',
+ label: 'Second',
+ sortable: true,
+ other: 'foo',
+ },
+ {
+ key: 'C',
+ label: 'Third',
+ },
+ 'D',
+];
+const TEST_ITEMS = [
+ { A: 1, B: 'lorem', C: 2, D: null, E: 'dne' },
+ { A: 2, B: 'ipsum', C: 2, D: null, E: 'dne' },
+ { A: 3, B: 'dolar', C: 2, D: null, E: 'dne' },
+];
+
+describe('behaviors/components/json_table', () => {
+ let wrapper;
+
+ const buildWrapper = ({
+ fields = [],
+ items = [],
+ filter = undefined,
+ caption = undefined,
+ } = {}) => {
+ wrapper = shallowMountExtended(JSONTable, {
+ propsData: {
+ fields,
+ items,
+ hasFilter: filter,
+ caption,
+ },
+ stubs: {
+ GlTable: merge(stubComponent(GlTable), {
+ props: {
+ fields: {
+ type: Array,
+ required: true,
+ },
+ items: {
+ type: Array,
+ required: true,
+ },
+ },
+ template: RENDER_ALL_SLOTS_TEMPLATE,
+ }),
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ const findTable = () => wrapper.findComponent(GlTable);
+ const findTableCaption = () => wrapper.findByTestId('slot-table-caption');
+ const findFilterInput = () => wrapper.findComponent(GlFormInput);
+
+ describe('default', () => {
+ beforeEach(() => {
+ buildWrapper();
+ });
+
+ it('renders gltable', () => {
+ expect(findTable().props()).toEqual({
+ fields: [],
+ items: [],
+ });
+ expect(findTable().attributes()).toMatchObject({
+ filter: '',
+ 'show-empty': '',
+ });
+ });
+
+ it('does not render filter input', () => {
+ expect(findFilterInput().exists()).toBe(false);
+ });
+
+ it('renders caption', () => {
+ expect(findTableCaption().text()).toBe('Generated with JSON data');
+ });
+ });
+
+ describe('with filter', () => {
+ beforeEach(() => {
+ buildWrapper({
+ filter: true,
+ });
+ });
+
+ it('renders filter input', () => {
+ expect(findFilterInput().attributes()).toMatchObject({
+ value: '',
+ placeholder: 'Type to search',
+ });
+ });
+
+ it('when input is changed, updates table filter', async () => {
+ findFilterInput().vm.$emit('input', 'New value!');
+
+ await nextTick();
+
+ expect(findTable().attributes('filter')).toBe('New value!');
+ });
+ });
+
+ describe('with fields', () => {
+ beforeEach(() => {
+ buildWrapper({
+ fields: TEST_FIELDS,
+ items: TEST_ITEMS,
+ });
+ });
+
+ it('passes cleaned fields and items to table', () => {
+ expect(findTable().props()).toEqual({
+ fields: [
+ 'A',
+ {
+ key: 'B',
+ label: 'Second',
+ sortable: true,
+ },
+ {
+ key: 'C',
+ label: 'Third',
+ sortable: false,
+ },
+ 'D',
+ ],
+ items: TEST_ITEMS,
+ });
+ });
+ });
+
+ describe('with full mount', () => {
+ beforeEach(() => {
+ wrapper = mountExtended(JSONTable, {
+ propsData: {
+ fields: [],
+ items: [],
+ },
+ });
+ });
+
+ // We want to make sure all the props are passed down nicely in integration
+ it('renders table without errors', () => {
+ expect(findTable().exists()).toBe(true);
+ });
+ });
+});
diff --git a/spec/frontend/behaviors/markdown/render_json_table_spec.js b/spec/frontend/behaviors/markdown/render_json_table_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..488492479f3bfbc69d86caf8d2cba015e26ff4e7
--- /dev/null
+++ b/spec/frontend/behaviors/markdown/render_json_table_spec.js
@@ -0,0 +1,119 @@
+import { nextTick } from 'vue';
+import { renderJSONTable } from '~/behaviors/markdown/render_json_table';
+
+describe('behaviors/markdown/render_json_table', () => {
+ let element;
+
+ const TEST_DATA = {
+ fields: [
+ { label: 'Field 1', key: 'a' },
+ { label: 'F 2', key: 'b' },
+ { label: 'F 3', key: 'c' },
+ ],
+ items: [
+ {
+ a: '1',
+ b: 'b',
+ c: 'c',
+ },
+ {
+ a: '2',
+ b: 'd',
+ c: 'e',
+ },
+ ],
+ };
+ const TEST_LABELS = TEST_DATA.fields.map((x) => x.label);
+
+ const tableAsData = (table) => ({
+ head: Array.from(table.querySelectorAll('thead th')).map((td) => td.textContent),
+ body: Array.from(table.querySelectorAll('tbody > tr')).map((tr) =>
+ Array.from(tr.querySelectorAll('td')).map((x) => x.textContent),
+ ),
+ });
+
+ const createTestSubject = async (json) => {
+ if (element) {
+ throw new Error('element has already been initialized');
+ }
+
+ const parent = document.createElement('div');
+ const pre = document.createElement('pre');
+
+ pre.textContent = json;
+ parent.appendChild(pre);
+
+ document.body.appendChild(parent);
+ renderJSONTable([parent]);
+
+ element = parent;
+
+ jest.runAllTimers();
+
+ await nextTick();
+ };
+
+ const findPres = () => document.querySelectorAll('pre');
+ const findTables = () => document.querySelectorAll('table');
+ const findAlerts = () => document.querySelectorAll('.gl-alert');
+ const findInputs = () => document.querySelectorAll('.gl-form-input');
+
+ afterEach(() => {
+ document.body.innerHTML = '';
+ element = null;
+ });
+
+ describe('default', () => {
+ beforeEach(async () => {
+ await createTestSubject(JSON.stringify(TEST_DATA, null, 2));
+ });
+
+ it('removes pre', () => {
+ expect(findPres()).toHaveLength(0);
+ });
+
+ it('replaces pre with table', () => {
+ const tables = findTables();
+
+ expect(tables).toHaveLength(1);
+ expect(tableAsData(tables[0])).toEqual({
+ head: TEST_LABELS,
+ body: [
+ ['1', 'b', 'c'],
+ ['2', 'd', 'e'],
+ ],
+ });
+ });
+
+ it('does not show filter', () => {
+ expect(findInputs()).toHaveLength(0);
+ });
+ });
+
+ describe('with invalid json', () => {
+ beforeEach(() => {
+ createTestSubject('funky but not json');
+ });
+
+ it('preserves pre', () => {
+ expect(findPres()).toHaveLength(1);
+ });
+
+ it('shows alert', () => {
+ const alerts = findAlerts();
+
+ expect(alerts).toHaveLength(1);
+ expect(alerts[0].textContent).toMatchInterpolatedText('Unable to parse JSON');
+ });
+ });
+
+ describe('with filter set', () => {
+ beforeEach(() => {
+ createTestSubject(JSON.stringify({ ...TEST_DATA, filter: true }));
+ });
+
+ it('shows filter', () => {
+ expect(findInputs()).toHaveLength(1);
+ });
+ });
+});