diff --git a/app/assets/javascripts/glql/components/common/facade.vue b/app/assets/javascripts/glql/components/common/facade.vue index 964cd963a70986d00ad31c68aa141f113177bdd4..057ec6c5d2252fccdc950c8b2b60dade495e8195 100644 --- a/app/assets/javascripts/glql/components/common/facade.vue +++ b/app/assets/javascripts/glql/components/common/facade.vue @@ -31,7 +31,6 @@ export default { data() { return { loadOnClick: true, - presenterPreview: null, presenterComponent: null, error: { diff --git a/app/assets/javascripts/glql/components/presenters/issuable.vue b/app/assets/javascripts/glql/components/presenters/issuable.vue index eb4791d00855c4d2ede46a31a37334a715e867b7..07b305c1ac44d09cdb4f65314a0cfbf8f439f678 100644 --- a/app/assets/javascripts/glql/components/presenters/issuable.vue +++ b/app/assets/javascripts/glql/components/presenters/issuable.vue @@ -46,6 +46,7 @@ export default { <template> <gl-link ref="reference" + class="gl-text-strong" :class="`gfm gfm-${type}`" :data-original="`${data.reference}+`" :data-reference-type="type" diff --git a/app/assets/javascripts/glql/components/presenters/list.vue b/app/assets/javascripts/glql/components/presenters/list.vue index 01e196a01424a0db7bff18eb6ae60000dd4f8d46..d0381b510d8399a6111088f9b948055d7c9f7251 100644 --- a/app/assets/javascripts/glql/components/presenters/list.vue +++ b/app/assets/javascripts/glql/components/presenters/list.vue @@ -2,6 +2,7 @@ import { GlIcon, GlIntersperse, GlLink, GlSprintf, GlSkeletonLoader } from '@gitlab/ui'; import { helpPagePath } from '~/helpers/help_page_helper'; import { __ } from '~/locale'; +import CrudComponent from '~/vue_shared/components/crud_component.vue'; export default { name: 'ListPresenter', @@ -11,6 +12,7 @@ export default { GlLink, GlSprintf, GlSkeletonLoader, + CrudComponent, }, inject: ['presenter'], props: { @@ -37,11 +39,14 @@ export default { }, }, computed: { + title() { + return this.config.title || __('GLQL list'); + }, items() { return this.data.nodes || []; }, fields() { - return this.config.fields; + return this.config.fields?.filter((item) => item.key !== 'title'); }, docsPath() { return `${helpPagePath('user/glql/_index')}#glql-views`; @@ -53,8 +58,14 @@ export default { }; </script> <template> - <div class="gl-mb-4"> - <component :is="listType" class="!gl-mb-1" data-testid="list"> + <crud-component + :title="title" + :description="config.description" + :count="items.length" + is-collapsible + class="!gl-mt-5" + > + <component :is="listType" class="content-list !gl-mb-0" data-testid="list"> <template v-if="isPreview"> <li v-for="i in 5" :key="i"> <gl-skeleton-loader :width="400" :lines="1" /> @@ -64,8 +75,13 @@ export default { <li v-for="(item, itemIndex) in items" :key="itemIndex" + class="gl-py-3" + :class="{ 'gl-border-b gl-border-b-section': itemIndex !== items.length - 1 }" :data-testid="`list-item-${itemIndex}`" > + <h3 class="!gl-heading-5 !gl-mb-1"> + <component :is="presenter.forField(item, 'title')" /> + </h3> <gl-intersperse separator=" · "> <span v-for="field in fields" :key="field.key"> <component :is="presenter.forField(item, field.key)" /> @@ -77,16 +93,16 @@ export default { {{ __('No data found for this query') }} </div> </component> - <div - class="gl-mt-3 gl-flex gl-items-center gl-gap-1 gl-text-sm gl-text-subtle" - data-testid="footer" - > - <gl-icon class="gl-mb-1 gl-mr-1" :size="12" name="tanuki" /> - <gl-sprintf :message="$options.i18n.generatedMessage"> - <template #link="{ content }"> - <gl-link :href="docsPath" target="_blank">{{ content }}</gl-link> - </template> - </gl-sprintf> - </div> - </div> + + <template #footer> + <div class="gl-flex gl-items-center gl-gap-1 gl-text-sm gl-text-subtle" data-testid="footer"> + <gl-icon class="gl-mb-1 gl-mr-1" :size="12" name="tanuki" /> + <gl-sprintf :message="$options.i18n.generatedMessage"> + <template #link="{ content }"> + <gl-link :href="docsPath" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> + </div> + </template> + </crud-component> </template> diff --git a/app/assets/javascripts/glql/components/presenters/table.vue b/app/assets/javascripts/glql/components/presenters/table.vue index 1a82e2df51e2ee2f86b54a8909074147bb27db88..81ba44b8814edc6a2ed67f5ae92251c678ee0f17 100644 --- a/app/assets/javascripts/glql/components/presenters/table.vue +++ b/app/assets/javascripts/glql/components/presenters/table.vue @@ -2,6 +2,7 @@ import { GlIcon, GlLink, GlSprintf, GlSkeletonLoader } from '@gitlab/ui'; import { helpPagePath } from '~/helpers/help_page_helper'; import { __ } from '~/locale'; +import CrudComponent from '~/vue_shared/components/crud_component.vue'; import Sorter from '../../core/sorter'; import ThResizable from '../common/th_resizable.vue'; @@ -13,6 +14,7 @@ export default { GlSprintf, GlSkeletonLoader, ThResizable, + CrudComponent, }, inject: ['presenter'], props: { @@ -39,11 +41,13 @@ export default { items, fields: this.config.fields, sorter: new Sorter(items), - table: null, }; }, computed: { + title() { + return this.config.title || __('GLQL table'); + }, docsPath() { return `${helpPagePath('user/glql/_index')}#glql-views`; }, @@ -59,61 +63,71 @@ export default { }; </script> <template> - <div class="gl-table-shadow !gl-my-4"> - <table ref="table" class="!gl-mb-2 !gl-mt-0 gl-overflow-y-hidden"> - <thead> - <tr v-if="table"> - <th-resizable v-for="(field, fieldIndex) in fields" :key="field.key" :table="table"> - <div - :data-testid="`column-${fieldIndex}`" - class="gl-cursor-pointer" - @click="sorter.sortBy(field.key)" - > - {{ field.label }} - <gl-icon - v-if="sorter.options.fieldName === field.key" - :name="sorter.options.ascending ? 'arrow-up' : 'arrow-down'" - /> - </div> - </th-resizable> - </tr> - </thead> - <tbody> - <template v-if="isPreview"> - <tr v-for="i in 5" :key="i"> - <td v-for="field in fields" :key="field.key"> - <gl-skeleton-loader :width="120" :lines="1" /> - </td> + <crud-component + :title="title" + :description="config.description" + :count="items.length" + is-collapsible + class="!gl-mt-5 gl-overflow-hidden" + body-class="!gl-m-[-1px] !gl-p-0" + footer-class="!gl-border-t-0" + > + <div class="gl-table-shadow"> + <table ref="table" class="!gl-my-0 gl-overflow-y-hidden"> + <thead class="gl-text-sm"> + <tr v-if="table"> + <th-resizable v-for="(field, fieldIndex) in fields" :key="field.key" :table="table"> + <div + :data-testid="`column-${fieldIndex}`" + class="gl-cursor-pointer" + @click="sorter.sortBy(field.key)" + > + {{ field.label }} + <gl-icon + v-if="sorter.options.fieldName === field.key" + :name="sorter.options.ascending ? 'arrow-up' : 'arrow-down'" + /> + </div> + </th-resizable> </tr> - </template> - <template v-else-if="items.length"> - <tr - v-for="(item, itemIndex) in items" - :key="item.id" - :data-testid="`table-row-${itemIndex}`" - > - <td v-for="field in fields" :key="field.key"> - <component :is="presenter.forField(item, field.key)" /> + </thead> + <tbody class="!gl-bg-subtle"> + <template v-if="isPreview"> + <tr v-for="i in 5" :key="i"> + <td v-for="field in fields" :key="field.key"> + <gl-skeleton-loader :width="120" :lines="1" /> + </td> + </tr> + </template> + <template v-else-if="items.length"> + <tr + v-for="(item, itemIndex) in items" + :key="item.id" + :data-testid="`table-row-${itemIndex}`" + > + <td v-for="field in fields" :key="field.key"> + <component :is="presenter.forField(item, field.key)" /> + </td> + </tr> + </template> + <tr v-else-if="!items.length"> + <td :colspan="fields.length" class="gl-text-center"> + {{ __('No data found for this query') }} </td> </tr> - </template> - <tr v-else-if="!items.length"> - <td :colspan="fields.length" class="gl-text-center"> - {{ __('No data found for this query') }} - </td> - </tr> - </tbody> - </table> - <div - class="gl-mt-3 gl-flex gl-items-center gl-gap-1 gl-text-sm gl-text-subtle" - data-testid="footer" - > - <gl-icon class="gl-mb-1 gl-mr-1" :size="12" name="tanuki" /> - <gl-sprintf :message="$options.i18n.generatedMessage"> - <template #link="{ content }"> - <gl-link :href="docsPath" target="_blank">{{ content }}</gl-link> - </template> - </gl-sprintf> + </tbody> + </table> </div> - </div> + + <template #footer> + <div class="gl-flex gl-items-center gl-gap-1 gl-text-sm gl-text-subtle" data-testid="footer"> + <gl-icon class="gl-mb-1 gl-mr-1" :size="12" name="tanuki" /> + <gl-sprintf :message="$options.i18n.generatedMessage"> + <template #link="{ content }"> + <gl-link :href="docsPath" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> + </div> + </template> + </crud-component> </template> diff --git a/app/assets/javascripts/glql/core/presenter.js b/app/assets/javascripts/glql/core/presenter.js index e62e9b67ff7f026a24c4156c7a997a8c57849994..9969146f6fead5947429ec3318eca6f074bc4da6 100644 --- a/app/assets/javascripts/glql/core/presenter.js +++ b/app/assets/javascripts/glql/core/presenter.js @@ -38,7 +38,6 @@ const presentersByFieldName = { const presentersByDisplayType = { list: ListPresenter, orderedList: ListPresenter, - table: TablePresenter, }; diff --git a/app/assets/javascripts/vue_shared/components/crud_component.vue b/app/assets/javascripts/vue_shared/components/crud_component.vue index 2e83d1ca7a26beaa5fcb5535b0675b034c7192ea..e859c4bf1dc38275f4dbde34bbf06aad4ae04d5f 100644 --- a/app/assets/javascripts/vue_shared/components/crud_component.vue +++ b/app/assets/javascripts/vue_shared/components/crud_component.vue @@ -1,11 +1,12 @@ <script> -import { GlButton, GlIcon, GlLoadingIcon, GlLink, GlTooltipDirective } from '@gitlab/ui'; +import { GlButton, GlIcon, GlBadge, GlLoadingIcon, GlLink, GlTooltipDirective } from '@gitlab/ui'; import { __ } from '~/locale'; export default { components: { GlButton, GlIcon, + GlBadge, GlLoadingIcon, GlLink, }, @@ -83,6 +84,11 @@ export default { required: false, default: null, }, + footerClass: { + type: [String, Object], + required: false, + default: null, + }, persistCollapsedState: { type: Boolean, required: false, @@ -208,14 +214,15 @@ export default { > <template v-if="displayedCount"> <gl-icon v-if="icon" :name="icon" variant="subtle" data-testid="crud-icon" /> - {{ displayedCount }} + <template v-if="icon">{{ displayedCount }}</template> + <gl-badge v-else class="gl-self-baseline">{{ displayedCount }}</gl-badge> </template> <slot v-if="$scopedSlots.count" name="count"></slot> </span> </h2> <p v-if="description || $scopedSlots.description" - class="gl-mb-0 gl-mt-2 gl-text-sm gl-leading-normal gl-text-subtle" + class="!gl-mb-0 !gl-mt-2 !gl-text-sm !gl-leading-normal !gl-text-subtle" data-testid="crud-description" > <slot v-if="$scopedSlots.description" name="description"></slot> @@ -283,8 +290,9 @@ export default { </div> <footer - v-if="$scopedSlots.footer" + v-if="isContentVisible && $scopedSlots.footer" class="gl-border-t gl-rounded-b-base gl-border-section gl-bg-section gl-px-5 gl-py-4" + :class="footerClass" data-testid="crud-footer" > <slot name="footer"></slot> diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss index 601927366f742be81cef97d0a91e55f85fc8b27d..56e330a9bc0aa5dfc3a5aa7d7a42350bd45985d0 100644 --- a/app/assets/stylesheets/framework/typography.scss +++ b/app/assets/stylesheets/framework/typography.scss @@ -118,7 +118,7 @@ @apply gl-heading-1-fixed gl-mt-7 gl-pb-2 gl-border-b; } - h2 { + h2:not(:where(.crud-header) h2) { @apply gl-heading-2-fixed gl-mt-6 gl-pb-2 gl-border-b; } @@ -203,13 +203,24 @@ .gl-table-shadow { overflow-x: auto; + overflow-y: hidden; animation: gl-table-shadow $gl-easing-out-cubic; animation-timeline: scroll(self inline); + // Needs to stay white because we use mix-blend-mode + // to make it work in both light and darkmode. + background-color: var(--gl-color-neutral-0); table:not(.code) { display: table; overflow-x: initial; mix-blend-mode: multiply; + // strech is not available yet, so we have to rely + // on the vendor prefixed ones below + min-width: strech; + /* stylelint-disable-next-line value-no-vendor-prefix */ + min-width: -webkit-fill-available; + /* stylelint-disable-next-line value-no-vendor-prefix */ + min-width: -moz-available; } } diff --git a/doc/user/glql/_index.md b/doc/user/glql/_index.md index c2792c88cc13d3f833bdd4b1221726a328873826..990ac22f3b8b7ad42bd6ad99c976eaed16d8e860 100644 --- a/doc/user/glql/_index.md +++ b/doc/user/glql/_index.md @@ -91,6 +91,7 @@ Values can include: {{< history >}} - [Changed](https://gitlab.com/gitlab-org/gitlab/-/issues/508956) in GitLab 17.7: Configuring the presentation layer using YAML front matter is deprecated. +- Parameters `title` and `description` [introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/183709) in GitLab 17.10. {{< /history >}} @@ -113,7 +114,7 @@ Views can be embedded in the following areas: The syntax of views is a superset of YAML that consists of: - The `query` parameter: Expressions joined together with a logical operator, such as `and`. -- Parameters related to the presentation layer, like `display`, `limit`, or `fields`. +- Parameters related to the presentation layer, like `display`, `limit`, or `fields`, `title`, and `description`. A GLQL view is defined in Markdown as a code block, similar to other code blocks like Mermaid. @@ -125,6 +126,8 @@ For example: ````yaml ```glql display: table +title: GLQL table 🎉 +description: This view lists my open issues fields: title, state, health, epic, milestone, weight, updated limit: 5 query: project = "gitlab-org/gitlab" AND assignee = currentUser() AND state = opened @@ -133,20 +136,22 @@ query: project = "gitlab-org/gitlab" AND assignee = currentUser() AND state = op This query should render a table like the one below: - + #### Presentation syntax Aside from the `query` parameter, you can configure presentation details for your GLQL query using some -more parameters. +more optional parameters. Supported parameters: -| Parameter | Default | Description | -| --------- | ------- | ----------- | -| `display` | `table` | How to display the data. Supported options: `table`, `list`, or `orderedList`. | -| `limit` | `100` | How many items to display. The maximum value is `100`. | -| `fields` | `title` | A comma-separated list of [fields](fields.md). All fields allowed in columns of a GLQL view are supported. | +| Parameter | Default | Description | +| ------------- | --------------------------- | ----------- | +| `description` | None | An optional description to display below the title. | +| `display` | `table` | How to display the data. Supported options: `table`, `list`, or `orderedList`. | +| `fields` | `title` | A comma-separated list of [fields](fields.md). All fields allowed in columns of a GLQL view are supported. | +| `limit` | `100` | How many items to display. The maximum value is `100`. | +| `title` | `GLQL table` or `GLQL list` | A title displayed at the top of the GLQL view. | For example, to display first five issues assigned to current user in the `gitlab-org/gitlab` project as a list, displaying fields `title`, `health`, and `due`: diff --git a/doc/user/glql/img/glql_table_v17_10.png b/doc/user/glql/img/glql_table_v17_10.png new file mode 100644 index 0000000000000000000000000000000000000000..b4023e32df4db769b5db96b7ded3ac9af498d1e4 Binary files /dev/null and b/doc/user/glql/img/glql_table_v17_10.png differ diff --git a/doc/user/glql/img/glql_table_v17_8.png b/doc/user/glql/img/glql_table_v17_8.png deleted file mode 100644 index ceb6b5ed3a6e962470af6e942b72468e2424c5ff..0000000000000000000000000000000000000000 Binary files a/doc/user/glql/img/glql_table_v17_8.png and /dev/null differ diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 1c648d7eff94def6a862a3b34583b3aee8f091e7..74bbe55f1985fd9e1c8a0419d7bcb10da1188b7c 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -25891,6 +25891,12 @@ msgstr "" msgid "GCP region configured" msgstr "" +msgid "GLQL list" +msgstr "" + +msgid "GLQL table" +msgstr "" + msgid "GLQL view timed out. Add more filters to reduce the number of results." msgstr "" diff --git a/spec/frontend/glql/components/presenters/list_spec.js b/spec/frontend/glql/components/presenters/list_spec.js index 2a107f103c3ffb24980f05d99386f4d29ab9f578..fd7925e23542ba9fa3de7a06cf3ad89421d99e83 100644 --- a/spec/frontend/glql/components/presenters/list_spec.js +++ b/spec/frontend/glql/components/presenters/list_spec.js @@ -51,9 +51,9 @@ describe('ListPresenter', () => { expect(htmlPresenter1.props('data')).toBe(MOCK_ISSUES.nodes[0].description); expect(htmlPresenter2.props('data')).toBe(MOCK_ISSUES.nodes[1].description); - expect(listItem1.text()).toEqual('Issue 1 (#1) · @foobar · Open · This is a description'); + expect(listItem1.text()).toEqual('Issue 1 (#1) @foobar · Open · This is a description'); expect(listItem2.text()).toEqual( - 'Issue 2 (#2 - closed) · @janedoe · Closed · This is another description', + 'Issue 2 (#2 - closed) @janedoe · Closed · This is another description', ); });