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 @@ + + 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); + }); + }); +});