diff --git a/app/assets/javascripts/glql/components/presenters/table.vue b/app/assets/javascripts/glql/components/presenters/table.vue new file mode 100644 index 0000000000000000000000000000000000000000..f6dc575258394494f5a5da53527245f1748742f0 --- /dev/null +++ b/app/assets/javascripts/glql/components/presenters/table.vue @@ -0,0 +1,60 @@ +<script> +import { toSentenceCase } from '../../utils/common'; + +export default { + name: 'TablePresenter', + inject: ['presenter'], + props: { + data: { + required: true, + type: Object, + validator: ({ nodes }) => Array.isArray(nodes), + }, + config: { + required: true, + type: Object, + validator: ({ fields }) => Array.isArray(fields) && fields.length > 0, + }, + }, + computed: { + items() { + return this.data.nodes; + }, + fields() { + return this.config.fields.map((field) => { + return { key: field, label: toSentenceCase(field) }; + }); + }, + }, +}; +</script> +<template> + <div class="!gl-my-4"> + <table class="!gl-mt-0 !gl-mb-2"> + <thead> + <tr> + <th v-for="field in fields" :key="field.key"> + {{ field.label }} + </th> + </tr> + </thead> + <tbody> + <tr + v-for="(item, itemIndex) in items" + :key="itemIndex" + :data-testid="`table-row-${itemIndex}`" + > + <td v-for="(field, fieldIndex) in fields" :key="fieldIndex"> + <component :is="presenter.forField(item, field.key)" /> + </td> + </tr> + <tr v-if="!items.length"> + <td :colspan="fields.length" class="gl-text-center"> + <em>{{ __('No data found for this query') }}</em> + </td> + </tr> + </tbody> + </table> + <small>{{ __('Generated by GLQL') }}</small> + </div> +</template> diff --git a/app/assets/javascripts/glql/core/presenter.js b/app/assets/javascripts/glql/core/presenter.js index bbcf8fc632e9a5071360f55ae00b76ea2149e13f..d001de87de6adb15015f642b581a1bd57cba51d9 100644 --- a/app/assets/javascripts/glql/core/presenter.js +++ b/app/assets/javascripts/glql/core/presenter.js @@ -3,10 +3,13 @@ import LinkPresenter from '../components/presenters/link.vue'; import TextPresenter from '../components/presenters/text.vue'; import ListPresenter from '../components/presenters/list.vue'; import NullPresenter from '../components/presenters/null.vue'; +import TablePresenter from '../components/presenters/table.vue'; const presentersByDisplayType = { list: ListPresenter, orderedList: ListPresenter, + + table: TablePresenter, }; const olProps = { listType: 'ol' }; diff --git a/app/assets/javascripts/glql/utils/common.js b/app/assets/javascripts/glql/utils/common.js index 58006820b5ec91f864f2a03ccdbd4c0cc8c2a443..14302e3905308f52529684f28f38884b8dfde07a 100644 --- a/app/assets/javascripts/glql/utils/common.js +++ b/app/assets/javascripts/glql/utils/common.js @@ -1,8 +1,7 @@ import jsYaml from 'js-yaml'; -import { uniq } from 'lodash'; +import { uniq, upperFirst, lowerCase } from 'lodash'; -export const extractGroupOrProject = () => { - const url = window.location.href; +export const extractGroupOrProject = (url = window.location.href) => { let fullPath = url .replace(window.location.origin, '') .split('/-/')[0] @@ -31,3 +30,8 @@ export const parseFrontmatter = (frontmatter, defaults = {}) => { config.display = config.display || 'list'; return config; }; + +export const toSentenceCase = (str) => { + if (str === 'id' || str === 'iid') return str.toUpperCase(); + return upperFirst(lowerCase(str)); +}; diff --git a/spec/frontend/glql/components/presenters/table_spec.js b/spec/frontend/glql/components/presenters/table_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..4639735b7c6a5edd570211be2591fd2cc2584d6c --- /dev/null +++ b/spec/frontend/glql/components/presenters/table_spec.js @@ -0,0 +1,62 @@ +import { shallowMountExtended, mountExtended } from 'helpers/vue_test_utils_helper'; +import TablePresenter from '~/glql/components/presenters/table.vue'; +import TextPresenter from '~/glql/components/presenters/text.vue'; +import LinkPresenter from '~/glql/components/presenters/link.vue'; +import Presenter from '~/glql/core/presenter'; +import { MOCK_ISSUES, MOCK_FIELDS } from '../../mock_data'; + +describe('TablePresenter', () => { + let wrapper; + + const createWrapper = ({ data, config, ...moreProps }, mountFn = shallowMountExtended) => { + wrapper = mountFn(TablePresenter, { + provide: { + presenter: new Presenter().init({ data, config }), + }, + propsData: { data, config, ...moreProps }, + }); + }; + + it('renders header rows with sentence cased field names', () => { + createWrapper({ data: MOCK_ISSUES, config: { fields: MOCK_FIELDS } }); + + const headerRow = wrapper.find('thead tr'); + const headerCells = headerRow.findAll('th').wrappers.map((th) => th.text()); + + expect(headerCells).toEqual(['Title', 'Author', 'State']); + }); + + it('renders a row of items presented by appropriate presenters', () => { + createWrapper({ data: MOCK_ISSUES, config: { fields: MOCK_FIELDS } }, mountExtended); + + const tableRow1 = wrapper.findByTestId('table-row-0'); + const tableRow2 = wrapper.findByTestId('table-row-1'); + + const linkPresenters1 = tableRow1.findAllComponents(LinkPresenter); + const linkPresenters2 = tableRow2.findAllComponents(LinkPresenter); + const textPresenter1 = tableRow1.findComponent(TextPresenter); + const textPresenter2 = tableRow2.findComponent(TextPresenter); + + expect(linkPresenters1).toHaveLength(2); + expect(linkPresenters2).toHaveLength(2); + + expect(linkPresenters1.at(0).props('data')).toBe(MOCK_ISSUES.nodes[0]); + expect(linkPresenters1.at(1).props('data')).toBe(MOCK_ISSUES.nodes[0].author); + expect(linkPresenters2.at(0).props('data')).toBe(MOCK_ISSUES.nodes[1]); + expect(linkPresenters2.at(1).props('data')).toBe(MOCK_ISSUES.nodes[1].author); + + expect(textPresenter1.props('data')).toBe(MOCK_ISSUES.nodes[0].state); + expect(textPresenter2.props('data')).toBe(MOCK_ISSUES.nodes[1].state); + + const getCells = (row) => row.findAll('td').wrappers.map((td) => td.text()); + + expect(getCells(tableRow1)).toEqual(['Issue 1', 'foobar', 'opened']); + expect(getCells(tableRow2)).toEqual(['Issue 2', 'janedoe', 'closed']); + }); + + it('shows a "No data" message if the list of items provided is empty', () => { + createWrapper({ data: { nodes: [] }, config: { fields: MOCK_FIELDS } }); + + expect(wrapper.text()).toContain('No data found for this query'); + }); +}); diff --git a/spec/frontend/glql/core/presenter_spec.js b/spec/frontend/glql/core/presenter_spec.js index ca8197093b35ec1fdb61e8abbd33feb00db34a51..663676ee331a15856493e30d384f7d4e2e3650d9 100644 --- a/spec/frontend/glql/core/presenter_spec.js +++ b/spec/frontend/glql/core/presenter_spec.js @@ -2,6 +2,7 @@ import Presenter, { componentForField } from '~/glql/core/presenter'; import LinkPresenter from '~/glql/components/presenters/link.vue'; import TextPresenter from '~/glql/components/presenters/text.vue'; import ListPresenter from '~/glql/components/presenters/list.vue'; +import TablePresenter from '~/glql/components/presenters/table.vue'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import { MOCK_FIELDS, MOCK_ISSUES } from '../mock_data'; @@ -18,12 +19,13 @@ describe('componentForField', () => { describe('Presenter', () => { it.each` - displayType | additionalProps - ${'list'} | ${{ listType: 'ul' }} - ${'orderedList'} | ${{ listType: 'ol' }} + displayType | additionalProps | PresenterComponent + ${'list'} | ${{ listType: 'ul' }} | ${ListPresenter} + ${'orderedList'} | ${{ listType: 'ol' }} | ${ListPresenter} + ${'table'} | ${{}} | ${TablePresenter} `( - 'inits a ListPresenter for displayType: $displayType with additionalProps: $additionalProps', - async ({ displayType, additionalProps }) => { + 'inits appropriate presenter component for displayType: $displayType with additionalProps: $additionalProps', + async ({ displayType, additionalProps, PresenterComponent }) => { const element = document.createElement('div'); element.innerHTML = '<pre><code data-canonical-lang="glql">assignee = currentUser()</code></pre>'; @@ -32,12 +34,12 @@ describe('Presenter', () => { const { component } = await new Presenter().init({ data, config }); const wrapper = mountExtended(component); - const listPresenter = wrapper.findComponent(ListPresenter); + const presenter = wrapper.findComponent(PresenterComponent); - expect(listPresenter.exists()).toBe(true); - expect(listPresenter.props('data')).toBe(data); - expect(listPresenter.props('config')).toBe(config); - expect(listPresenter.props()).toMatchObject(additionalProps); + expect(presenter.exists()).toBe(true); + expect(presenter.props('data')).toBe(data); + expect(presenter.props('config')).toBe(config); + expect(presenter.props()).toMatchObject(additionalProps); }, ); }); diff --git a/spec/frontend/glql/utils/common_spec.js b/spec/frontend/glql/utils/common_spec.js index 0af6547c460018115465eb2eaeeac468452514ea..949afa5567236a53b82db3ce2b303ca5499b2850 100644 --- a/spec/frontend/glql/utils/common_spec.js +++ b/spec/frontend/glql/utils/common_spec.js @@ -1,4 +1,9 @@ -import { extractGroupOrProject, parseQueryText, parseFrontmatter } from '~/glql/utils/common'; +import { + extractGroupOrProject, + parseQueryText, + parseFrontmatter, + toSentenceCase, +} from '~/glql/utils/common'; import { useMockLocationHelper } from 'helpers/mock_window_location_helper'; describe('extractGroupOrProject', () => { @@ -72,3 +77,16 @@ describe('parseFrontmatter', () => { }); }); }); + +describe('toSentenceCase', () => { + it.each` + str | expected + ${'title'} | ${'Title'} + ${'camelCasedExample'} | ${'Camel cased example'} + ${'snake_case_example'} | ${'Snake case example'} + ${'id'} | ${'ID'} + ${'iid'} | ${'IID'} + `('returns $expected for $str', ({ str, expected }) => { + expect(toSentenceCase(str)).toBe(expected); + }); +});