From a2003de16196f9c44e9c7b5396179423ddcd30be Mon Sep 17 00:00:00 2001 From: Chen Charnolevsky <ccharnolevsky@gitlab.com> Date: Tue, 26 Nov 2024 17:49:56 +0000 Subject: [PATCH] Marking code flow content issues --- .../code_flow/code_flow_file_viewer.vue | 9 +- .../code_flow/code_flow_steps_section.vue | 96 +------- .../components/code_flow/utils/utils.js | 120 +++++++++- .../code_flow/vulnerability_code_flow.vue | 8 +- .../vulnerability_file_content_viewer.vue | 44 ++-- .../code_flow/code_flow_file_viewer_spec.js | 9 +- .../components/code_flow/utils/utils_spec.js | 212 ++++++++++++++++++ .../vulnerability_file_content_viewer_spec.js | 2 + 8 files changed, 386 insertions(+), 114 deletions(-) diff --git a/ee/app/assets/javascripts/vue_shared/components/code_flow/code_flow_file_viewer.vue b/ee/app/assets/javascripts/vue_shared/components/code_flow/code_flow_file_viewer.vue index d5994a6051ac1..5b8d2c0a93375 100644 --- a/ee/app/assets/javascripts/vue_shared/components/code_flow/code_flow_file_viewer.vue +++ b/ee/app/assets/javascripts/vue_shared/components/code_flow/code_flow_file_viewer.vue @@ -41,6 +41,11 @@ export default { type: String, required: true, }, + selectedStepNumber: { + type: Number, + required: false, + default: undefined, + }, }, data() { return { @@ -95,7 +100,6 @@ export default { } else { this.highlightedContent = res; } - this.$emit('codeFlowFileLoaded'); }) .catch((error) => { Sentry.captureException(error); @@ -173,6 +177,7 @@ export default { class="file-content code js-syntax-highlight blob-content blob-viewer gl-flex gl-w-full gl-flex-col gl-overflow-auto" :class="userColorScheme" data-type="simple" + data-testid="file-content" > <div v-for="(highlightSectionInfo, index) in codeBlocks" :key="index"> <div @@ -198,7 +203,7 @@ export default { :start-line="highlightSectionInfo.blockStartLine" :end-line="highlightSectionInfo.blockEndLine" :highlight-info="highlightSectionInfo.highlightInfo" - @codeFlowFileLoaded="$emit('codeFlowFileLoaded')" + :selected-step-number="selectedStepNumber" /> <div v-if="isEndOfCodeBlock(index)" class="expansion-line gl-bg-gray-50 gl-p-1"> diff --git a/ee/app/assets/javascripts/vue_shared/components/code_flow/code_flow_steps_section.vue b/ee/app/assets/javascripts/vue_shared/components/code_flow/code_flow_steps_section.vue index 3c43089cc8341..0389609bedc94 100644 --- a/ee/app/assets/javascripts/vue_shared/components/code_flow/code_flow_steps_section.vue +++ b/ee/app/assets/javascripts/vue_shared/components/code_flow/code_flow_steps_section.vue @@ -11,15 +11,6 @@ import { } from '@gitlab/ui'; import { flattenDeep } from 'lodash'; import { __, s__, sprintf } from '~/locale'; -import { - numMarkerIdPrefix, - selectedInlineItemMark, - selectedInlineSectionMarker, - selectedInlineNumberMark, - textMarkerIdPrefix, - textSpanMarkerIdPrefix, - unselectedInlineNumberMark, -} from 'ee/vue_shared/components/code_flow/utils/constants'; export default { name: 'CodeFlowStepsSection', @@ -103,6 +94,9 @@ export default { mounted() { this.stepsExpanded = Array(this.vulnerabilityFlowDetails.length).fill(true); }, + created() { + this.$emit('onSelectedStep', this.selectedStepNumber); + }, methods: { openFileSteps(index) { const copyStepsExpanded = [...this.stepsExpanded]; @@ -121,71 +115,7 @@ export default { selectStep(vulnerabilityItem) { this.selectedStepNumber = vulnerabilityItem.stepNumber; this.selectedVulnerability = vulnerabilityItem; - this.markdownBlobData(); - this.scrollToSpecificCodeFlow(); - }, - markdownRowContent() { - // Highlights the selected markdown row content - const elements = document.querySelectorAll(`[id^=${textMarkerIdPrefix}]`); - elements.forEach((el) => { - el.classList.remove(selectedInlineSectionMarker); - }); - - // Examples of ID: 'TEXT-MARKER-1,2-L8', 'TEXT-MARKER-3-L7' - const stepMarkerSelectors = [ - `[id^="${textMarkerIdPrefix}"][id*="${this.selectedStepNumber}-L"]`, - `[id^="${textMarkerIdPrefix}"][id*=",${this.selectedStepNumber}-L"]`, - `[id^="${textMarkerIdPrefix}"][id*="${this.selectedStepNumber},"][id*="-L"]`, - ]; - const selector = stepMarkerSelectors.join(', '); - const element = document.querySelectorAll(selector); - - if (element) { - element.forEach((el) => el.classList.add(selectedInlineSectionMarker)); - } - }, - markdownStepNumber() { - // Highlights the step number in the markdown - const elements = document.querySelectorAll(`[id^=${textMarkerIdPrefix}]`); - elements.forEach((el) => { - const spans = el.querySelectorAll('span.inline-item-mark'); - spans.forEach((span) => { - span.classList.remove(selectedInlineItemMark); - }); - }); - const element = document.querySelector( - `[id^="${textSpanMarkerIdPrefix}${this.selectedStepNumber}"]`, - ); - if (element) { - element.classList.add(selectedInlineItemMark, 'gs'); - } - }, - markdownRowNumber() { - // Highlights the row number in the markdown - const elements = document.querySelectorAll(`[id^="${numMarkerIdPrefix}"]`); - elements.forEach((el) => { - el.classList.remove(selectedInlineNumberMark); - el.classList.add(unselectedInlineNumberMark); - }); - - // Examples of ID: 'NUM-MARKER-1,2-L8', 'NUM-MARKER-3-L7' - const numMarkerSelectors = [ - `[id^="${numMarkerIdPrefix}"][id*="${this.selectedStepNumber}-L"]`, - `[id^="${numMarkerIdPrefix}"][id*=",${this.selectedStepNumber}-L"]`, - `[id^="${numMarkerIdPrefix}"][id*="${this.selectedStepNumber},"][id*="-L"]`, - ]; - const selector = numMarkerSelectors.join(', '); - const element = document.querySelector(selector); - - if (element) { - element.classList.add(selectedInlineNumberMark); - element.classList.remove(unselectedInlineNumberMark); - } - }, - markdownBlobData() { - this.markdownRowContent(); - this.markdownStepNumber(); - this.markdownRowNumber(); + this.$emit('onSelectedStep', this.selectedStepNumber); }, getNextIndex(isNext) { return isNext ? this.selectedStepNumber + 1 : this.selectedStepNumber - 1; @@ -200,23 +130,7 @@ export default { (item) => item.stepNumber === this.getNextIndex(isNextVulnerability), ); this.selectedStepNumber = this.selectedVulnerability.stepNumber; - this.markdownBlobData(); - this.scrollToSpecificCodeFlow(); - }, - scrollToSpecificCodeFlow() { - const element = document.querySelector( - `[id^=${textMarkerIdPrefix}${this.selectedStepNumber}]`, - ); - if (element) { - const subScroller = document.querySelector(`[id=code-flows-container]`); - const subScrollerRect = subScroller.getBoundingClientRect(); - const elementRect = element.getBoundingClientRect(); - const offsetTop = elementRect.top - subScrollerRect.top + subScroller.scrollTop; - subScroller.scrollTo({ - top: offsetTop - subScroller.clientHeight / 2 + element.clientHeight / 2, - behavior: 'smooth', - }); - } + this.$emit('onSelectedStep', this.selectedStepNumber); }, showNodeTypePopover(nodeType) { return nodeType === 'source' diff --git a/ee/app/assets/javascripts/vue_shared/components/code_flow/utils/utils.js b/ee/app/assets/javascripts/vue_shared/components/code_flow/utils/utils.js index d5af92c6448dc..6beb22a89b1af 100644 --- a/ee/app/assets/javascripts/vue_shared/components/code_flow/utils/utils.js +++ b/ee/app/assets/javascripts/vue_shared/components/code_flow/utils/utils.js @@ -1,5 +1,14 @@ import { differenceWith, isEqual } from 'lodash'; -import { linesPadding } from 'ee/vue_shared/components/code_flow/utils/constants'; +import { + linesPadding, + numMarkerIdPrefix, + selectedInlineItemMark, + selectedInlineNumberMark, + selectedInlineSectionMarker, + textMarkerIdPrefix, + textSpanMarkerIdPrefix, + unselectedInlineNumberMark, +} from 'ee/vue_shared/components/code_flow/utils/constants'; import { splitByLineBreaks } from '~/vue_shared/components/source_viewer/workers/highlight_utils'; /** @@ -145,3 +154,112 @@ export const createHighlightSourcesInfo = (normalizedCodeFlows, rawTextBlobs) => return acc; }, {}); }; + +/** + * Generates an array of CSS selector strings for identifying elements based on a prefix and step number. + * @param {string} idPrefix - The prefix of the ID to match. + * @param {number} selectedStepNumber - The selected step number to include in the selectors. + * @returns {string[]} An array of three CSS selector strings. + */ +const getIdSelectors = (idPrefix, selectedStepNumber) => { + // Examples of ID: '<idPrefix>-<selectedStepNumber>>,2-L8', '<idPrefix>-<selectedStepNumber>-L7' + // Examples of ID: 'NUM-MARKER-1,2-L8', 'NUM-MARKER-3-L7' + // Examples of ID: 'TEXT-MARKER-1,2-L8', 'TEXT-MARKER-3-L7' + return [ + `[id^="${idPrefix}"][id*="${selectedStepNumber}-L"]`, + `[id^="${idPrefix}"][id*=",${selectedStepNumber}-L"]`, + `[id^="${idPrefix}"][id*="${selectedStepNumber},"][id*="-L"]`, + ]; +}; + +/** + * Highlights the content of the selected markdown row and unhighlights others. + * @param {number} selectedStepNumber - The step number corresponding to the row to highlight. + */ +export const markdownRowContent = (selectedStepNumber) => { + // Highlights the selected markdown row content + const textMarkerElements = document.querySelectorAll(`[id^=${textMarkerIdPrefix}]`); + textMarkerElements.forEach((el) => { + el.classList.remove(selectedInlineSectionMarker); + }); + + const textMarkerSelectors = getIdSelectors(textMarkerIdPrefix, selectedStepNumber); + const selector = textMarkerSelectors.join(', '); + const allTextMarkerElements = document.querySelectorAll(selector); + if (allTextMarkerElements.length > 0) { + allTextMarkerElements.forEach((el) => el.classList.add(selectedInlineSectionMarker)); + } +}; + +/** + * Highlights the selected step number in the markdown and unhighlights others. + * @param {number} selectedStepNumber - The step number to highlight. + */ +export const markdownStepNumber = (selectedStepNumber) => { + // Highlights the step number in the markdown + const textMarkerElements = document.querySelectorAll(`[id^=${textMarkerIdPrefix}]`); + textMarkerElements.forEach((el) => { + const spans = el.querySelectorAll('span.inline-item-mark'); + spans.forEach((span) => { + span.classList.remove(selectedInlineItemMark); + }); + }); + const textSpanMarkerElements = document.querySelectorAll( + `[id^="${textSpanMarkerIdPrefix}${selectedStepNumber}"]`, + ); + if (textSpanMarkerElements.length > 0) { + textSpanMarkerElements.forEach((el) => el.classList.add(selectedInlineItemMark, 'gs')); + } +}; + +/** + * Highlights the selected row number in the markdown and unhighlights others. + * @param {number} selectedStepNumber - The step number to highlight. + */ +export const markdownRowNumber = (selectedStepNumber) => { + // Highlights the row number in the markdown + const numMarkerElements = document.querySelectorAll(`[id^="${numMarkerIdPrefix}"]`); + numMarkerElements.forEach((el) => { + el.classList.remove(selectedInlineNumberMark); + el.classList.add(unselectedInlineNumberMark); + }); + + const numMarkerSelectors = getIdSelectors(numMarkerIdPrefix, selectedStepNumber); + const selector = numMarkerSelectors.join(', '); + const allNumMarkerElements = document.querySelectorAll(selector); + + if (allNumMarkerElements.length > 0) { + allNumMarkerElements.forEach((el) => { + el.classList.add(selectedInlineNumberMark); + el.classList.remove(unselectedInlineNumberMark); + }); + } +}; + +/** + * call the markdown functions when the step is selected + * @param {Number} selectedStepNumber - The selected step number. + */ +export const markdownBlobData = (selectedStepNumber) => { + markdownRowContent(selectedStepNumber); + markdownStepNumber(selectedStepNumber); + markdownRowNumber(selectedStepNumber); +}; + +/** + * Scrolls to a specific code flow element within a container. + * @param {number} selectedStepNumber - The number of the step to scroll to. + */ +export const scrollToSpecificCodeFlow = (selectedStepNumber) => { + const element = document.querySelector(`[id^=${textMarkerIdPrefix}${selectedStepNumber}]`); + if (element) { + const subScroller = document.getElementById('code-flows-container'); + const subScrollerRect = subScroller.getBoundingClientRect(); + const elementRect = element.getBoundingClientRect(); + const offsetTop = elementRect.top - subScrollerRect.top + subScroller.scrollTop; + subScroller.scrollTo({ + top: offsetTop - subScroller.clientHeight / 2 + element.clientHeight / 2, + behavior: 'smooth', + }); + } +}; diff --git a/ee/app/assets/javascripts/vue_shared/components/code_flow/vulnerability_code_flow.vue b/ee/app/assets/javascripts/vue_shared/components/code_flow/vulnerability_code_flow.vue index 889fd1962f758..ce01d4d9eff4a 100644 --- a/ee/app/assets/javascripts/vue_shared/components/code_flow/vulnerability_code_flow.vue +++ b/ee/app/assets/javascripts/vue_shared/components/code_flow/vulnerability_code_flow.vue @@ -55,6 +55,7 @@ export default { rawTextBlobs: {}, highlightSourcesInfo: {}, modalContentHeight: 0, + selectedStepNumber: undefined, }; }, computed: { @@ -110,8 +111,8 @@ export default { }, }, methods: { - codeFlowFileLoaded() { - this.$refs.codeFlowStepSection.markdownBlobData(); + onSelectedStep(value) { + this.selectedStepNumber = value; }, specificBlobInfo(fileName) { return this.blobInfo[fileName]?.data || {}; @@ -154,6 +155,7 @@ export default { :description-html="descriptionHtml" :details="details" :raw-text-blobs="rawTextBlobs" + @onSelectedStep="onSelectedStep" /> <div @@ -170,7 +172,7 @@ export default { :branch-ref="branchRef" :hl-info="highlightSourcesInfo[fileName]" :blob-info="specificBlobInfo(fileName)" - @codeFlowFileLoaded="codeFlowFileLoaded" + :selected-step-number="selectedStepNumber" /> </div> </template> diff --git a/ee/app/assets/javascripts/vulnerabilities/components/vulnerability_file_content_viewer.vue b/ee/app/assets/javascripts/vulnerabilities/components/vulnerability_file_content_viewer.vue index aae9b89403878..87d3b9d0c2887 100644 --- a/ee/app/assets/javascripts/vulnerabilities/components/vulnerability_file_content_viewer.vue +++ b/ee/app/assets/javascripts/vulnerabilities/components/vulnerability_file_content_viewer.vue @@ -5,6 +5,10 @@ import { unselectedInlineNumberMark, numMarkerIdPrefix, } from 'ee/vue_shared/components/code_flow/utils/constants'; +import { + markdownBlobData, + scrollToSpecificCodeFlow, +} from 'ee/vue_shared/components/code_flow/utils/utils'; export default { name: 'VulnerabilityFileContentViewer', @@ -18,11 +22,7 @@ export default { isHighlighted: { type: Boolean, required: false, default: false }, content: { type: String, required: true }, highlightInfo: { type: Array, required: false, default: () => [] }, - }, - data() { - return { - loadingError: false, - }; + selectedStepNumber: { type: Number, required: false, default: undefined }, }, computed: { lineNumbers() { @@ -58,16 +58,34 @@ export default { }, }, watch: { - lineNumbers() { - // When the user clicks `handleToggleFile` in `code_flow_file_viewer.vue` parent, - // The `expanded` value changed, and the `lineNumbers` are recalculated and updated in the DOM. - // We need to ensure that the highlighting of the selected step in the code flow viewer - // remains intact for the user. - this.$nextTick(() => { - this.$emit('codeFlowFileLoaded'); - }); + /** + * when the user selects a different step in the code flow + * we need to highlight the new row and scroll to it + */ + selectedStepNumber() { + markdownBlobData(this.selectedStepNumber); + scrollToSpecificCodeFlow(this.selectedStepNumber); }, }, + mounted() { + /** + * when the user closes and opens the collapse, this component is mounted again, + * so we need to highlight the new row and scroll to it + */ + markdownBlobData(this.selectedStepNumber); + scrollToSpecificCodeFlow(this.selectedStepNumber); + }, + /** + * 1. when the component is load for the first time, the sourceCodeLines is empty + * (because of async function 'highlightContent' in the parent), + * so when sourceCodeLines is updated, we need to highlight the new row and scroll to it + * 2. when the user opens more code blocks, this component is updated, + * so we need to highlight the new row and scroll to it + */ + updated() { + markdownBlobData(this.selectedStepNumber); + scrollToSpecificCodeFlow(this.selectedStepNumber); + }, methods: { lineNumberId(line) { return `${numMarkerIdPrefix}${line.stepNumbers}-L${line.lineNumber}`; diff --git a/ee/spec/frontend/vue_shared/components/code_flow/code_flow_file_viewer_spec.js b/ee/spec/frontend/vue_shared/components/code_flow/code_flow_file_viewer_spec.js index da06fc9a4657b..1ccf4f7bf131c 100644 --- a/ee/spec/frontend/vue_shared/components/code_flow/code_flow_file_viewer_spec.js +++ b/ee/spec/frontend/vue_shared/components/code_flow/code_flow_file_viewer_spec.js @@ -21,6 +21,7 @@ describe('Vulnerability Code Flow File Viewer component', () => { filePath: 'samples/test.js', branchRef: '123', hlInfo: [], + selectedStepNumber: 1, }; const hlInfo = [ @@ -59,13 +60,13 @@ describe('Vulnerability Code Flow File Viewer component', () => { filePath: defaultProps.filePath, branchRef: defaultProps.branchRef, hlInfo: defaultProps.hlInfo, + selectedStepNumber: defaultProps.selectedStepNumber, ...props, }, - stubs: { GlSprintf, GlButton }, + stubs: { GlSprintf, GlButton, BlobHeader }, }); }; - const findButton = () => wrapper.findComponent(GlButton); const findVulFileContentViewer = () => wrapper.findComponent(VulnerabilityFileContentViewer); const findBlobHeader = () => wrapper.findComponent(BlobHeader); const findGlAlert = () => wrapper.findComponent(GlAlert); @@ -97,7 +98,7 @@ describe('Vulnerability Code Flow File Viewer component', () => { it('shows the source code without markdown', () => { createWrapper(); expect(findBlobHeader().exists()).toBe(true); - expect(findButton().exists()).toBe(true); + expect(findCollapseExpandButton().exists()).toBe(true); expect(findVulFileContentViewer().exists()).toBe(false); }); @@ -115,7 +116,7 @@ describe('Vulnerability Code Flow File Viewer component', () => { it('renders GlButton with correct aria-label when file is expanded', () => { createWrapper(); - expect(findButton().attributes('aria-label')).toBe('Hide file contents'); + expect(findCollapseExpandButton().attributes('aria-label')).toBe('Hide file contents'); }); it('renders the expand buttons with correct aria-label', () => { diff --git a/ee/spec/frontend/vue_shared/components/code_flow/utils/utils_spec.js b/ee/spec/frontend/vue_shared/components/code_flow/utils/utils_spec.js index 7baa63da27ee0..6ad20f495a8f1 100644 --- a/ee/spec/frontend/vue_shared/components/code_flow/utils/utils_spec.js +++ b/ee/spec/frontend/vue_shared/components/code_flow/utils/utils_spec.js @@ -5,6 +5,10 @@ import { normalizeCodeFlowsInfo, createHighlightSourcesInfo, createSourceBlockHighlightInfo, + scrollToSpecificCodeFlow, + markdownRowContent, + markdownRowNumber, + markdownStepNumber, } from 'ee/vue_shared/components/code_flow/utils/utils'; jest.mock('~/vue_shared/components/source_viewer/workers/highlight_utils', () => ({ @@ -12,6 +16,12 @@ jest.mock('~/vue_shared/components/source_viewer/workers/highlight_utils', () => splitByLineBreaks: jest.fn((text) => text.split('\n')), })); +const mockQuerySelector = jest.fn(); +const mockGetElementById = jest.fn(); +const mockScrollTo = jest.fn(); +document.querySelector = mockQuerySelector; +document.getElementById = mockGetElementById; + describe('updateCodeBlocks', () => { it('should return an empty array when input is empty', () => { expect(updateCodeBlocks([])).toEqual([]); @@ -317,3 +327,205 @@ describe('createSourceBlockHighlightInfo', () => { }); }); }); + +describe('scrollToSpecificCodeFlow', () => { + beforeEach(() => { + const mockElement = { + getBoundingClientRect: jest.fn(() => ({ + top: 300, + })), + clientHeight: 20, + }; + + const mockSubScroller = { + getBoundingClientRect: jest.fn(() => ({ + top: 100, + })), + scrollTo: mockScrollTo, + scrollTop: 50, + clientHeight: 500, + }; + + mockQuerySelector.mockImplementation((selector) => { + if (selector.startsWith('[id^=TEXT-MARKER')) { + return mockElement; + } + return null; + }); + + mockGetElementById.mockImplementation((id) => { + if (id === 'code-flows-container') { + return mockSubScroller; + } + return null; + }); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('should scroll to the specific code flow when element is found', () => { + scrollToSpecificCodeFlow(5); + expect(mockQuerySelector).toHaveBeenCalledWith('[id^=TEXT-MARKER-5]'); + expect(mockGetElementById).toHaveBeenCalledWith('code-flows-container'); + expect(mockScrollTo).toHaveBeenCalledWith({ + top: 10, // (300 - 100 + 50) - 500/2 + 20/2 + behavior: 'smooth', + }); + }); + + it('should not scroll when the specific element is not found', () => { + mockQuerySelector.mockImplementation((selector) => { + if (selector === '[id=code-flows-container]') { + return { + getBoundingClientRect: jest.fn(() => ({ + top: 100, + })), + scrollTo: mockScrollTo, + scrollTop: 50, + clientHeight: 500, + }; + } + return null; // Simulating element not found + }); + scrollToSpecificCodeFlow(10); + expect(mockQuerySelector).toHaveBeenCalledWith('[id^=TEXT-MARKER-10]'); + expect(mockScrollTo).not.toHaveBeenCalled(); + }); + + it('should handle different step numbers', () => { + scrollToSpecificCodeFlow(1); + expect(mockQuerySelector).toHaveBeenCalledWith('[id^=TEXT-MARKER-1]'); + + scrollToSpecificCodeFlow(100); + expect(mockQuerySelector).toHaveBeenCalledWith('[id^=TEXT-MARKER-100]'); + }); +}); + +describe('markdownRowContent', () => { + const createMockElement = (id, className = '') => { + const el = document.createElement('div'); + el.setAttribute('id', id); + if (className) { + el.classList.add(className); + } + document.body.appendChild(el); + return el; + }; + + afterEach(() => { + document.body.innerHTML = ''; + }); + + it('should remove `selected-inline-section-marker` class from all elements starting with `TEXT-MARKER-`', () => { + const mockEl1 = createMockElement('TEXT-MARKER-1', 'selected-inline-section-marker'); + const mockEl2 = createMockElement('TEXT-MARKER-2', 'selected-inline-section-marker'); + const mockEl3 = createMockElement('TEXT-MARKER-3', 'some-other-class'); + markdownRowContent(1); + expect(mockEl1.classList.contains('selected-inline-section-marker')).toBe(false); + expect(mockEl2.classList.contains('selected-inline-section-marker')).toBe(false); + expect(mockEl3.classList.contains('selected-inline-section-marker')).toBe(false); + }); + + it('should add `selected-inline-section-marker` class to elements matching the selectedStepNumber', () => { + const mockEl1 = createMockElement('TEXT-MARKER-1-L7'); + const mockEl2 = createMockElement('TEXT-MARKER-2-L7'); + const mockEl3 = createMockElement('TEXT-MARKER-1,2-L7'); + markdownRowContent(1); + expect(mockEl1.classList.contains('selected-inline-section-marker')).toBe(true); + expect(mockEl2.classList.contains('selected-inline-section-marker')).toBe(false); + expect(mockEl3.classList.contains('selected-inline-section-marker')).toBe(true); + }); + + it('should do nothing if no element matches the selectedStepNumber', () => { + const mockEl1 = createMockElement('TEXT-MARKER-2-L7'); + markdownRowContent(3); + expect(mockEl1.classList.contains('selected-inline-section-marker')).toBe(false); + }); +}); + +describe('markdownStepNumber', () => { + const createMockElement = (id, innerHTML = '') => { + const el = document.createElement('span'); + el.setAttribute('id', id); + el.setAttribute('class', ''); + if (innerHTML) { + el.innerHTML = innerHTML; + } + document.body.appendChild(el); + return el; + }; + + afterEach(() => { + document.body.innerHTML = ''; + }); + + it('should remove `selected-inline-item-mark` class from all span elements inside matching `TEXT-MARKER-` elements', () => { + const mockEl1 = createMockElement( + `TEXT-MARKER-1`, + '<span class="inline-item-mark selected-inline-item-mark"></span>', + ); + const mockEl2 = createMockElement( + `TEXT-MARKER-2`, + '<span class="inline-item-mark selected-inline-item-mark"></span>', + ); + markdownStepNumber(1); + const span1 = mockEl1.querySelector('span.inline-item-mark'); + const span2 = mockEl2.querySelector('span.inline-item-mark'); + expect(span1.classList.contains('selected-inline-item-mark')).toBe(false); + expect(span2.classList.contains('selected-inline-item-mark')).toBe(false); + }); + + it('should add `selected-inline-item-mark` and `gs` classes to the element matching selectedStepNumber', () => { + const matchingEl = createMockElement(`TEXT-SPAN-MARKER-1`); + markdownStepNumber(1); + expect(matchingEl.classList.contains('selected-inline-item-mark')).toBe(true); + expect(matchingEl.classList.contains('gs')).toBe(true); + }); + + it('should do nothing if no element matches the selectedStepNumber', () => { + const nonMatchingEl = createMockElement(`TEXT-SPAN-MARKER-2`); + markdownStepNumber(1); + expect(nonMatchingEl.classList.contains('selected-inline-item-mark')).toBe(false); + expect(nonMatchingEl.classList.contains('gs')).toBe(false); + }); +}); + +describe('markdownRowNumber', () => { + const createMockElement = (id, classNames = '') => { + const el = document.createElement('div'); + el.setAttribute('id', id); + el.className = classNames; + document.body.appendChild(el); + return el; + }; + + afterEach(() => { + document.body.innerHTML = ''; + }); + + it('should remove `selected-inline-number-mark` and add `unselected-inline-number-mark` to all elements with id starting with "NUM-MARKER-"', () => { + const mockEl1 = createMockElement('NUM-MARKER-1', 'selected-inline-number-mark'); + const mockEl2 = createMockElement('NUM-MARKER-2', 'selected-inline-number-mark'); + markdownRowNumber(1); + expect(mockEl1.classList.contains('selected-inline-number-mark')).toBe(false); + expect(mockEl1.classList.contains('unselected-inline-number-mark')).toBe(true); + expect(mockEl2.classList.contains('selected-inline-number-mark')).toBe(false); + expect(mockEl2.classList.contains('unselected-inline-number-mark')).toBe(true); + }); + + it('should add `selected-inline-number-mark` and remove `unselected-inline-number-mark` to element matching selectedStepNumber', () => { + const matchingEl = createMockElement('NUM-MARKER-1-L8', 'unselected-inline-number-mark'); + markdownRowNumber(1); + expect(matchingEl.classList.contains('selected-inline-number-mark')).toBe(true); + expect(matchingEl.classList.contains('unselected-inline-number-mark')).toBe(false); + }); + + it('should do nothing if no element matches the selectedStepNumber', () => { + const nonMatchingEl = createMockElement('NUM-MARKER-2-L8', 'unselected-inline-number-mark'); + markdownRowNumber(1); + expect(nonMatchingEl.classList.contains('selected-inline-number-mark')).toBe(false); + expect(nonMatchingEl.classList.contains('unselected-inline-number-mark')).toBe(true); + }); +}); diff --git a/ee/spec/frontend/vulnerabilities/vulnerability_file_content_viewer_spec.js b/ee/spec/frontend/vulnerabilities/vulnerability_file_content_viewer_spec.js index 8b84ff1a1e3fe..be94cb78336e0 100644 --- a/ee/spec/frontend/vulnerabilities/vulnerability_file_content_viewer_spec.js +++ b/ee/spec/frontend/vulnerabilities/vulnerability_file_content_viewer_spec.js @@ -12,6 +12,7 @@ const vulFileContent = { isHighlighted: false, content: 'line 1\nline 2\nline 3\nline 4\nline 5', highlightInfo: [], + selectedStepNumber: 1, }; const spanContent = '<span class="plain-lines">line 2\nline 3\nline 4</span>'; const codeContent = '<code class="d-block">line 2\nline 3\nline 4</code>'; @@ -26,6 +27,7 @@ describe('Vulnerability File Contents Viewer component', () => { isHighlighted: vulFileContent.isHighlighted, // Ensure this matches the required prop content: vulFileContent.content, highlightInfo: vulFileContent.highlightInfo, + selectedStepNumber: vulFileContent.selectedStepNumber, ...vulnerabilityOverrides, }; -- GitLab