From 1682504edf6d6e310bc956edbefe86591f043cf8 Mon Sep 17 00:00:00 2001 From: Jannik Lehmann <jlehmann@gitlab.com> Date: Wed, 13 Dec 2023 19:16:48 +0000 Subject: [PATCH] Add Slideshow for SAST and CodeQuality Inline Findings This commit is meant to solve: https://gitlab.com/gitlab-org/gitlab/-/issues/417033 It introduces a slideshow on the Inline findings drawer when there are multiple findings. EE: true --- .../components/shared/findings_drawer.vue | 99 +++++++++++---- .../inline_findings_gutter_icon_dropdown.vue | 28 +++-- ...line_findings_gutter_icon_dropdown_spec.js | 51 ++++++++ .../findings_drawer_spec.js.snap | 14 ++- .../components/shared/findings_drawer_spec.js | 113 ++++++++++++++---- .../diffs/mock_data/findings_drawer.js | 42 +++++++ 6 files changed, 278 insertions(+), 69 deletions(-) diff --git a/app/assets/javascripts/diffs/components/shared/findings_drawer.vue b/app/assets/javascripts/diffs/components/shared/findings_drawer.vue index 6a141ee28bab..854f6910fa1b 100644 --- a/app/assets/javascripts/diffs/components/shared/findings_drawer.vue +++ b/app/assets/javascripts/diffs/components/shared/findings_drawer.vue @@ -1,5 +1,5 @@ <script> -import { GlBadge, GlDrawer, GlIcon, GlLink } from '@gitlab/ui'; +import { GlBadge, GlDrawer, GlLink, GlButton, GlIcon } from '@gitlab/ui'; import { __, s__ } from '~/locale'; import { DRAWER_Z_INDEX } from '~/lib/utils/constants'; import { getSeverity } from '~/ci/reports/utils'; @@ -27,7 +27,7 @@ export const codeQuality = 'codeQuality'; export default { i18n, codeQuality, - components: { GlBadge, GlDrawer, GlIcon, GlLink, DrawerItem }, + components: { GlBadge, GlDrawer, GlLink, GlButton, GlIcon, DrawerItem }, props: { drawer: { type: Object, @@ -39,22 +39,50 @@ export default { default: () => {}, }, }, + data() { + return { + activeIndex: 0, + }; + }, computed: { getDrawerHeaderHeight() { return getContentWrapperHeight(); }, isCodeQuality() { - return this.drawer.scale === this.$options.codeQuality; + return this.activeElement.scale === this.$options.codeQuality; + }, + activeElement() { + return this.drawer.findings[this.activeIndex]; }, findingsStatus() { - return this.drawer.state === SAST_FINDING_DISMISSED ? 'muted' : 'warning'; + return this.activeElement.state === SAST_FINDING_DISMISSED ? 'muted' : 'warning'; }, }, DRAWER_Z_INDEX, + watch: { + drawer(newVal) { + this.activeIndex = newVal.index; + }, + }, methods: { getSeverity, + prev() { + if (this.activeIndex === 0) { + this.activeIndex = this.drawer.findings.length - 1; + } else { + this.activeIndex -= 1; + } + }, + next() { + if (this.activeIndex === this.drawer.findings.length - 1) { + this.activeIndex = 0; + } else { + this.activeIndex += 1; + } + }, + concatIdentifierName(name, index) { - return name + (index !== this.drawer.identifiers.length - 1 ? ', ' : ''); + return name + (index !== this.activeElement.identifiers.length - 1 ? ', ' : ''); }, }, }; @@ -68,38 +96,51 @@ export default { @close="$emit('close')" > <template #title> - <h2 class="drawer-heading gl-font-base gl-mt-0 gl-mb-0"> + <h2 class="drawer-heading gl-font-base gl-mt-0 gl-mb-0 gl-w-28"> <gl-icon :size="12" - :name="getSeverity(drawer).name" - :class="getSeverity(drawer).class" + :name="getSeverity(activeElement).name" + :class="getSeverity(activeElement).class" class="inline-findings-severity-icon gl-vertical-align-baseline!" /> - <span class="drawer-heading-severity">{{ drawer.severity }}</span> + <span class="drawer-heading-severity">{{ activeElement.severity }}</span> {{ isCodeQuality ? $options.i18n.codeQualityFinding : $options.i18n.sastFinding }} </h2> + <div v-if="drawer.findings.length > 1"> + <gl-button data-testid="findings-drawer-prev-button" class="gl-p-1!" @click="prev"> + <gl-icon :size="24" name="chevron-left" /> + </gl-button> + <gl-button class="gl-p-1!" @click="next"> + <gl-icon data-testid="findings-drawer-next-button" :size="24" name="chevron-right" /> + </gl-button> + </div> </template> <template #default> <ul class="gl-list-style-none gl-border-b-initial gl-mb-0 gl-pb-0!"> - <drawer-item v-if="drawer.title" :description="$options.i18n.name" :value="drawer.title" /> + <drawer-item + v-if="activeElement.title" + :description="$options.i18n.name" + :value="activeElement.title" + data-testid="findings-drawer-title" + /> - <drawer-item v-if="drawer.state" :description="$options.i18n.status"> + <drawer-item v-if="activeElement.state" :description="$options.i18n.status"> <template #value> <gl-badge :variant="findingsStatus" class="text-capitalize">{{ - drawer.state + activeElement.state }}</gl-badge> </template> </drawer-item> <drawer-item - v-if="drawer.description" + v-if="activeElement.description" :description="$options.i18n.description" - :value="drawer.description" + :value="activeElement.description" /> <drawer-item - v-if="project && drawer.scale !== $options.codeQuality" + v-if="project && activeElement.scale !== $options.codeQuality" :description="$options.i18n.project" > <template #value> @@ -107,23 +148,31 @@ export default { </template> </drawer-item> - <drawer-item v-if="drawer.location || drawer.webUrl" :description="$options.i18n.file"> + <drawer-item + v-if="activeElement.location || activeElement.webUrl" + :description="$options.i18n.file" + > <template #value> - <span v-if="drawer.webUrl && drawer.filePath && drawer.line"> - <gl-link :href="drawer.webUrl">{{ drawer.filePath }}:{{ drawer.line }}</gl-link> + <span v-if="activeElement.webUrl && activeElement.filePath && activeElement.line"> + <gl-link :href="activeElement.webUrl" + >{{ activeElement.filePath }}:{{ activeElement.line }}</gl-link + > </span> - <span v-else-if="drawer.location"> - {{ drawer.location.file }}:{{ drawer.location.startLine }} + <span v-else-if="activeElement.location"> + {{ activeElement.location.file }}:{{ activeElement.location.startLine }} </span> </template> </drawer-item> <drawer-item - v-if="drawer.identifiers && drawer.identifiers.length" + v-if="activeElement.identifiers && activeElement.identifiers.length" :description="$options.i18n.identifiers" > <template #value> - <span v-for="(identifier, index) in drawer.identifiers" :key="identifier.externalId"> + <span + v-for="(identifier, index) in activeElement.identifiers" + :key="identifier.externalId" + > <gl-link v-if="identifier.url" :href="identifier.url"> {{ concatIdentifierName(identifier.name, index) }} </gl-link> @@ -135,15 +184,15 @@ export default { </drawer-item> <drawer-item - v-if="drawer.scale" + v-if="activeElement.scale" :description="$options.i18n.tool" :value="isCodeQuality ? $options.i18n.codeQuality : $options.i18n.sast" /> <drawer-item - v-if="drawer.engineName" + v-if="activeElement.engineName" :description="$options.i18n.engine" - :value="drawer.engineName" + :value="activeElement.engineName" /> </ul> </template> diff --git a/ee/app/assets/javascripts/diffs/components/inline_findings_gutter_icon_dropdown.vue b/ee/app/assets/javascripts/diffs/components/inline_findings_gutter_icon_dropdown.vue index ed5587ecd1a2..e966ed966672 100644 --- a/ee/app/assets/javascripts/diffs/components/inline_findings_gutter_icon_dropdown.vue +++ b/ee/app/assets/javascripts/diffs/components/inline_findings_gutter_icon_dropdown.vue @@ -77,18 +77,22 @@ export default { }); } - groupedFindings.forEach((e) => { - e.items.map((arr) => { - // enhance groupedFindings to match GlDisclosureDropdown validator - // https://gitlab-org.gitlab.io/gitlab-ui/?path=/docs/base-new-dropdowns-disclosure--docs#setting-disclosure-dropdown-items - const enhancedGroupedFindings = arr; - enhancedGroupedFindings.text = arr.description; - enhancedGroupedFindings.action = () => this.toggleDrawer(arr); + const allLineFindings = this.flatFindings; - return enhancedGroupedFindings; - }); + // Enhance each finding with the correct index and action + allLineFindings.forEach((finding, index) => { + /* eslint-disable no-param-reassign */ + finding.action = () => this.toggleDrawer(allLineFindings, index); + // enhance to match GlDisclosureDropdown validator + // https://gitlab-org.gitlab.io/gitlab-ui/?path=/docs/base-new-dropdowns-disclosure--docs#setting-disclosure-dropdown-items + finding.text = finding.description; + /* eslint-enable no-param-reassign */ }); - return groupedFindings; + + return groupedFindings.map((group) => ({ + ...group, + items: group.items.map((item) => allLineFindings.find((f) => f === item)), + })); }, showMoreCount() { return this.moreCount && this.isHoveringFirstIcon; @@ -106,8 +110,8 @@ export default { }, }, methods: { - toggleDrawer(finding) { - this.setDrawer(finding); + toggleDrawer(findings, index) { + this.setDrawer({ findings, index }); }, ...mapActions('findingsDrawer', ['setDrawer']), }, diff --git a/ee/spec/frontend/diffs/components/inline_findings_gutter_icon_dropdown_spec.js b/ee/spec/frontend/diffs/components/inline_findings_gutter_icon_dropdown_spec.js index a5c393eb7e84..4c3da1e83c0a 100644 --- a/ee/spec/frontend/diffs/components/inline_findings_gutter_icon_dropdown_spec.js +++ b/ee/spec/frontend/diffs/components/inline_findings_gutter_icon_dropdown_spec.js @@ -109,6 +109,57 @@ describe('EE inlineFindingsGutterIconDropdown', () => { await itemElements.at(1).trigger('click'); expect(mockSetDrawer).toHaveBeenCalledTimes(2); }); + + it('calls setDrawer action with correct allLineFindings and index when an item action is triggered', async () => { + createComponent({ + filePath, + codeQuality: singularCodeQualityFinding, + sast: singularSastFinding, + }); + + const itemElements = findDropdownItems(); + await itemElements.at(0).trigger('click'); + const firstCallFirstArg = mockSetDrawer.mock.calls[0][1]; + + expect(firstCallFirstArg).toEqual({ + findings: [ + { + ...singularCodeQualityFinding[0], + action: expect.any(Function), + class: 'gl-text-orange-300', + name: 'severity-low', + }, + { + ...singularSastFinding[0], + action: expect.any(Function), + class: 'gl-text-orange-300', + name: 'severity-low', + }, + ], + index: 0, + }); + + await itemElements.at(1).trigger('click'); + const secondCall = mockSetDrawer.mock.calls[1][1]; + + expect(secondCall).toEqual({ + findings: [ + { + ...singularCodeQualityFinding[0], + action: expect.any(Function), + class: 'gl-text-orange-300', + name: 'severity-low', + }, + { + ...singularSastFinding[0], + action: expect.any(Function), + class: 'gl-text-orange-300', + name: 'severity-low', + }, + ], + index: 1, + }); + }); }); it('sets "isHoveringFirstIcon" to true when mouse enters the first icon', async () => { diff --git a/spec/frontend/diffs/components/shared/__snapshots__/findings_drawer_spec.js.snap b/spec/frontend/diffs/components/shared/__snapshots__/findings_drawer_spec.js.snap index d7364e805423..33a268c06ccc 100644 --- a/spec/frontend/diffs/components/shared/__snapshots__/findings_drawer_spec.js.snap +++ b/spec/frontend/diffs/components/shared/__snapshots__/findings_drawer_spec.js.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`FindingsDrawer matches the snapshot with detected badge 1`] = ` +exports[`FindingsDrawer General Rendering matches the snapshot with detected badge 1`] = ` <transition-stub class="findings-drawer" name="gl-drawer" @@ -16,7 +16,7 @@ exports[`FindingsDrawer matches the snapshot with detected badge 1`] = ` class="gl-drawer-title" > <h2 - class="drawer-heading gl-font-base gl-mb-0 gl-mt-0" + class="drawer-heading gl-font-base gl-mb-0 gl-mt-0 gl-w-28" > <svg aria-hidden="true" @@ -61,6 +61,7 @@ exports[`FindingsDrawer matches the snapshot with detected badge 1`] = ` > <li class="gl-mb-4" + data-testid="findings-drawer-title" > <p class="gl-line-height-20" @@ -219,7 +220,7 @@ exports[`FindingsDrawer matches the snapshot with detected badge 1`] = ` </transition-stub> `; -exports[`FindingsDrawer matches the snapshot with dismissed badge 1`] = ` +exports[`FindingsDrawer General Rendering matches the snapshot with dismissed badge 1`] = ` <transition-stub class="findings-drawer" name="gl-drawer" @@ -235,7 +236,7 @@ exports[`FindingsDrawer matches the snapshot with dismissed badge 1`] = ` class="gl-drawer-title" > <h2 - class="drawer-heading gl-font-base gl-mb-0 gl-mt-0" + class="drawer-heading gl-font-base gl-mb-0 gl-mt-0 gl-w-28" > <svg aria-hidden="true" @@ -280,6 +281,7 @@ exports[`FindingsDrawer matches the snapshot with dismissed badge 1`] = ` > <li class="gl-mb-4" + data-testid="findings-drawer-title" > <p class="gl-line-height-20" @@ -310,9 +312,9 @@ exports[`FindingsDrawer matches the snapshot with dismissed badge 1`] = ` Status </span> <span - class="badge badge-muted badge-pill gl-badge md text-capitalize" + class="badge badge-pill badge-warning gl-badge md text-capitalize" > - dismissed + detected </span> </p> </li> diff --git a/spec/frontend/diffs/components/shared/findings_drawer_spec.js b/spec/frontend/diffs/components/shared/findings_drawer_spec.js index a6528978e3bf..00b4ca262be5 100644 --- a/spec/frontend/diffs/components/shared/findings_drawer_spec.js +++ b/spec/frontend/diffs/components/shared/findings_drawer_spec.js @@ -1,3 +1,4 @@ +import { nextTick } from 'vue'; import { GlDrawer } from '@gitlab/ui'; import FindingsDrawer from '~/diffs/components/shared/findings_drawer.vue'; import { mountExtended } from 'helpers/vue_test_utils_helper'; @@ -5,41 +6,101 @@ import { mockFindingDismissed, mockFindingDetected, mockProject, + mockFindingsMultiple, } from '../../mock_data/findings_drawer'; -let wrapper; -const getDrawer = () => wrapper.findComponent(GlDrawer); -const closeEvent = 'close'; +describe('FindingsDrawer', () => { + let wrapper; -const createWrapper = (finding = mockFindingDismissed) => { - return mountExtended(FindingsDrawer, { - propsData: { - drawer: finding, - project: mockProject, - }, - }); -}; + const findPreviousButton = () => wrapper.findByTestId('findings-drawer-prev-button'); + const findNextButton = () => wrapper.findByTestId('findings-drawer-next-button'); + const findTitle = () => wrapper.findByTestId('findings-drawer-title'); + const createWrapper = ( + drawer = { findings: [mockFindingDetected], index: 0 }, + project = mockProject, + ) => { + return mountExtended(FindingsDrawer, { + propsData: { + drawer, + project, + }, + }); + }; -describe('FindingsDrawer', () => { - it('renders without errors', () => { - wrapper = createWrapper(); - expect(wrapper.exists()).toBe(true); - }); + describe('General Rendering', () => { + beforeEach(() => { + wrapper = createWrapper(); + }); + it('renders without errors', () => { + expect(wrapper.exists()).toBe(true); + }); - it('emits close event when gl-drawer emits close event', () => { - wrapper = createWrapper(); + it('emits close event when gl-drawer emits close event', () => { + wrapper.findComponent(GlDrawer).vm.$emit('close'); + expect(wrapper.emitted('close')).toHaveLength(1); + }); - getDrawer().vm.$emit(closeEvent); - expect(wrapper.emitted(closeEvent)).toHaveLength(1); + it('matches the snapshot with dismissed badge', () => { + expect(wrapper.element).toMatchSnapshot(); + }); + + it('matches the snapshot with detected badge', () => { + expect(wrapper.element).toMatchSnapshot(); + }); }); - it('matches the snapshot with dismissed badge', () => { - wrapper = createWrapper(); - expect(wrapper.element).toMatchSnapshot(); + describe('Prev/Next Buttons with Multiple Items', () => { + it('renders prev/next buttons when there are multiple items', () => { + wrapper = createWrapper({ findings: mockFindingsMultiple, index: 0 }); + expect(findPreviousButton().exists()).toBe(true); + expect(findNextButton().exists()).toBe(true); + }); + + it('does not render prev/next buttons when there is only one item', () => { + wrapper = createWrapper({ findings: [mockFindingDismissed], index: 0 }); + expect(findPreviousButton().exists()).toBe(false); + expect(findNextButton().exists()).toBe(false); + }); + + it('calls prev method on prev button click and loops correct activeIndex', async () => { + wrapper = createWrapper({ findings: mockFindingsMultiple, index: 0 }); + expect(findTitle().text()).toBe(`Name ${mockFindingsMultiple[0].title}`); + + await findPreviousButton().trigger('click'); + await nextTick(); + expect(findTitle().text()).toBe(`Name ${mockFindingsMultiple[2].title}`); + + await findPreviousButton().trigger('click'); + await nextTick(); + expect(findTitle().text()).toBe(`Name ${mockFindingsMultiple[1].title}`); + }); + + it('calls next method on next button click', async () => { + wrapper = createWrapper({ findings: mockFindingsMultiple, index: 0 }); + expect(findTitle().text()).toBe(`Name ${mockFindingsMultiple[0].title}`); + + await findNextButton().trigger('click'); + await nextTick(); + expect(findTitle().text()).toBe(`Name ${mockFindingsMultiple[1].title}`); + + await findNextButton().trigger('click'); + await nextTick(); + expect(findTitle().text()).toBe(`Name ${mockFindingsMultiple[2].title}`); + + await findNextButton().trigger('click'); + await nextTick(); + expect(findTitle().text()).toBe(`Name ${mockFindingsMultiple[0].title}`); + }); }); - it('matches the snapshot with detected badge', () => { - wrapper = createWrapper(mockFindingDetected); - expect(wrapper.element).toMatchSnapshot(); + describe('Active Index Handling', () => { + it('watcher sets active index on drawer prop change', async () => { + wrapper = createWrapper(); + const newFinding = { findings: mockFindingsMultiple, index: 2 }; + + await wrapper.setProps({ drawer: newFinding }); + await nextTick(); + expect(findTitle().text()).toBe(`Name ${mockFindingsMultiple[2].title}`); + }); }); }); diff --git a/spec/frontend/diffs/mock_data/findings_drawer.js b/spec/frontend/diffs/mock_data/findings_drawer.js index f6fc33b1dcfb..257a3b3e499a 100644 --- a/spec/frontend/diffs/mock_data/findings_drawer.js +++ b/spec/frontend/diffs/mock_data/findings_drawer.js @@ -31,3 +31,45 @@ export const mockProject = { nameWithNamespace: 'testname', fullPath: 'testpath', }; + +export const mockFindingsMultiple = [ + { + ...mockFindingDismissed, + title: 'Finding 1', + severity: 'critical', + engineName: 'Engine 1', + identifiers: [ + { + ...mockFindingDismissed.identifiers[0], + name: 'identifier 1', + url: 'https://example.com/identifier1', + }, + ], + }, + { + ...mockFindingDetected, + title: 'Finding 2', + severity: 'medium', + engineName: 'Engine 2', + identifiers: [ + { + ...mockFindingDetected.identifiers[0], + name: 'identifier 2', + url: 'https://example.com/identifier2', + }, + ], + }, + { + ...mockFindingDetected, + title: 'Finding 3', + severity: 'medium', + engineName: 'Engine 3', + identifiers: [ + { + ...mockFindingDetected.identifiers[0], + name: 'identifier 3', + url: 'https://example.com/identifier3', + }, + ], + }, +]; -- GitLab