diff --git a/app/assets/javascripts/repository/components/commit_info.vue b/app/assets/javascripts/repository/components/commit_info.vue index 99ec5c78830d9a76047d4bb8f8725e40ebc1b410..b6e3cdbb7a3db6347371f330cf1ea1932a856cda 100644 --- a/app/assets/javascripts/repository/components/commit_info.vue +++ b/app/assets/javascripts/repository/components/commit_info.vue @@ -53,7 +53,7 @@ export default { </script> <template> - <div class="well-segment commit gl-min-h-8 gl-p-5 gl-w-full gl-display-flex"> + <div class="well-segment commit gl-min-h-8 gl-p-2 gl-w-full gl-display-flex"> <user-avatar-link v-if="commit.author" :link-href="commit.author.webPath" diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/components/blame_info.vue b/app/assets/javascripts/vue_shared/components/source_viewer/components/blame_info.vue new file mode 100644 index 0000000000000000000000000000000000000000..9bce9402afa77d977540727375f0835b1cf06ae9 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/source_viewer/components/blame_info.vue @@ -0,0 +1,51 @@ +<script> +import { GlTooltipDirective } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; +import CommitInfo from '~/repository/components/commit_info.vue'; +import { calculateBlameOffset, toggleBlameClasses } from '../utils'; + +export default { + name: 'BlameInfo', + components: { + CommitInfo, + }, + directives: { + GlTooltip: GlTooltipDirective, + SafeHtml, + }, + props: { + blameData: { + type: Array, + required: true, + }, + }, + computed: { + blameInfo() { + return this.blameData.map((blame, index) => ({ + ...blame, + blameOffset: calculateBlameOffset(blame.lineno, index), + })); + }, + }, + mounted() { + toggleBlameClasses(this.blameData, true); + }, + destroyed() { + toggleBlameClasses(this.blameData, false); + }, +}; +</script> +<template> + <div class="blame gl-bg-gray-10"> + <div class="blame-commit gl-border-none!"> + <commit-info + v-for="(blame, index) in blameInfo" + :key="index" + :class="{ 'gl-border-t': index !== 0 }" + class="gl-display-flex gl-absolute gl-px-3" + :style="{ top: blame.blameOffset }" + :commit="blame.commit" + /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/utils.js b/app/assets/javascripts/vue_shared/components/source_viewer/utils.js new file mode 100644 index 0000000000000000000000000000000000000000..af01653fc0d96d80e31d39e46a1b2c2b773d5aff --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/source_viewer/utils.js @@ -0,0 +1,37 @@ +const BLAME_INFO_CLASSLIST = ['gl-border-t', 'gl-border-gray-500', 'gl-pt-3!']; +const PADDING_BOTTOM_LARGE = 'gl-pb-6!'; +const PADDING_BOTTOM_SMALL = 'gl-pb-3!'; + +const findLineNumberElement = (lineNumber) => document.getElementById(`L${lineNumber}`); + +const findLineContentElement = (lineNumber) => document.getElementById(`LC${lineNumber}`); + +export const calculateBlameOffset = (lineNumber) => { + if (lineNumber === 1) return '0px'; + const lineContentOffset = findLineContentElement(lineNumber)?.offsetTop; + return `${lineContentOffset}px`; +}; + +export const toggleBlameClasses = (blameData, isVisible) => { + /** + * Adds/removes classes to line number/content elements to match the line with the blame info + * */ + const method = isVisible ? 'add' : 'remove'; + blameData.forEach(({ lineno, span }) => { + const lineNumberEl = findLineNumberElement(lineno)?.parentElement; + const lineContentEl = findLineContentElement(lineno); + const lineNumberSpanEl = findLineNumberElement(lineno + span - 1)?.parentElement; + const lineContentSpanEl = findLineContentElement(lineno + span - 1); + + lineNumberEl?.classList[method](...BLAME_INFO_CLASSLIST); + lineContentEl?.classList[method](...BLAME_INFO_CLASSLIST); + + if (span === 1) { + lineNumberSpanEl?.classList[method](PADDING_BOTTOM_LARGE); + lineContentSpanEl?.classList[method](PADDING_BOTTOM_LARGE); + } else { + lineNumberSpanEl?.classList[method](PADDING_BOTTOM_SMALL); + lineContentSpanEl?.classList[method](PADDING_BOTTOM_SMALL); + } + }); +}; diff --git a/spec/frontend/vue_shared/components/source_viewer/__snapshots__/utils_spec.js.snap b/spec/frontend/vue_shared/components/source_viewer/__snapshots__/utils_spec.js.snap new file mode 100644 index 0000000000000000000000000000000000000000..e75b07dcf71c15607877e0ccb221b87b8361d09d --- /dev/null +++ b/spec/frontend/vue_shared/components/source_viewer/__snapshots__/utils_spec.js.snap @@ -0,0 +1,88 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SourceViewer utils toggleBlameClasses adds classes 1`] = ` +<div + class="content" +> + <div + class="gl-border-gray-500 gl-border-t gl-pt-3!" + > + <div + id="reference-0" + > + 1 + </div> + <div + id="reference-1" + > + 2 + </div> + <div + id="reference-2" + > + 3 + </div> + </div> + <div> + <div + class="gl-border-gray-500 gl-border-t gl-pt-3!" + id="reference-3" + > + Content 1 + </div> + <div + class="gl-border-gray-500 gl-border-t gl-pt-3!" + id="reference-4" + > + Content 2 + </div> + <div + class="gl-border-gray-500 gl-border-t gl-pt-3!" + id="reference-5" + > + Content 3 + </div> + </div> +</div> +`; + +exports[`SourceViewer utils toggleBlameClasses removes classes 1`] = ` +<div + class="content" +> + <div> + <div + id="reference-0" + > + 1 + </div> + <div + id="reference-1" + > + 2 + </div> + <div + id="reference-2" + > + 3 + </div> + </div> + <div> + <div + id="reference-3" + > + Content 1 + </div> + <div + id="reference-4" + > + Content 2 + </div> + <div + id="reference-5" + > + Content 3 + </div> + </div> +</div> +`; diff --git a/spec/frontend/vue_shared/components/source_viewer/components/blame_info_spec.js b/spec/frontend/vue_shared/components/source_viewer/components/blame_info_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..ff8b2be96340fb40e108b8c8afc2f2a877c6d060 --- /dev/null +++ b/spec/frontend/vue_shared/components/source_viewer/components/blame_info_spec.js @@ -0,0 +1,63 @@ +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { setHTMLFixture } from 'helpers/fixtures'; +import CommitInfo from '~/repository/components/commit_info.vue'; +import BlameInfo from '~/vue_shared/components/source_viewer/components/blame_info.vue'; +import * as utils from '~/vue_shared/components/source_viewer/utils'; +import { SOURCE_CODE_CONTENT_MOCK, BLAME_DATA_MOCK } from '../mock_data'; + +describe('BlameInfo component', () => { + let wrapper; + + const createComponent = () => { + wrapper = shallowMountExtended(BlameInfo, { + propsData: { blameData: BLAME_DATA_MOCK }, + }); + }; + + beforeEach(() => { + setHTMLFixture(SOURCE_CODE_CONTENT_MOCK); + jest.spyOn(utils, 'toggleBlameClasses'); + createComponent(); + }); + + const findCommitInfoComponents = () => wrapper.findAllComponents(CommitInfo); + + it('adds the necessary classes to the DOM', () => { + expect(utils.toggleBlameClasses).toHaveBeenCalledWith(BLAME_DATA_MOCK, true); + }); + + it('renders a CommitInfo component for each blame entry', () => { + expect(findCommitInfoComponents().length).toBe(BLAME_DATA_MOCK.length); + }); + + it.each(BLAME_DATA_MOCK)( + 'sets the correct data and positioning for the commitInfo', + ({ lineno, commit, index }) => { + const commitInfoComponent = findCommitInfoComponents().at(index); + + expect(commitInfoComponent.props('commit')).toEqual(commit); + expect(commitInfoComponent.element.style.top).toBe(utils.calculateBlameOffset(lineno)); + }, + ); + + describe('commitInfo component styling', () => { + const borderTopClassName = 'gl-border-t'; + + it('does not add a top border for the first entry', () => { + expect(findCommitInfoComponents().at(0).element.classList).not.toContain(borderTopClassName); + }); + + it('add a top border for the rest of the entries', () => { + expect(findCommitInfoComponents().at(1).element.classList).toContain(borderTopClassName); + expect(findCommitInfoComponents().at(2).element.classList).toContain(borderTopClassName); + }); + }); + + describe('when component is destroyed', () => { + beforeEach(() => wrapper.destroy()); + + it('resets the DOM to its original state', () => { + expect(utils.toggleBlameClasses).toHaveBeenCalledWith(BLAME_DATA_MOCK, false); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/source_viewer/mock_data.js b/spec/frontend/vue_shared/components/source_viewer/mock_data.js index f35e9607d5c535629e75a501bed228893c1f24bc..b3516f7ed72148ea7196ecde6d8a40a23c5ede8a 100644 --- a/spec/frontend/vue_shared/components/source_viewer/mock_data.js +++ b/spec/frontend/vue_shared/components/source_viewer/mock_data.js @@ -22,3 +22,24 @@ export const CHUNK_2 = { startingFrom: 70, blamePath, }; + +export const SOURCE_CODE_CONTENT_MOCK = ` +<div class="content"> + <div> + <div id="L1">1</div> + <div id="L2">2</div> + <div id="L3">3</div> + </div> + + <div> + <div id="LC1">Content 1</div> + <div id="LC2">Content 2</div> + <div id="LC3">Content 3</div> + </div> +</div>`; + +export const BLAME_DATA_MOCK = [ + { lineno: 1, commit: { author: 'Peter' }, index: 0 }, + { lineno: 2, commit: { author: 'Sarah' }, index: 1 }, + { lineno: 3, commit: { author: 'Peter' }, index: 2 }, +]; diff --git a/spec/frontend/vue_shared/components/source_viewer/utils_spec.js b/spec/frontend/vue_shared/components/source_viewer/utils_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..0ac72aa9afb3aa8d4fa59592b30039f5612e7d98 --- /dev/null +++ b/spec/frontend/vue_shared/components/source_viewer/utils_spec.js @@ -0,0 +1,35 @@ +import { setHTMLFixture } from 'helpers/fixtures'; +import { + calculateBlameOffset, + toggleBlameClasses, +} from '~/vue_shared/components/source_viewer/utils'; +import { SOURCE_CODE_CONTENT_MOCK, BLAME_DATA_MOCK } from './mock_data'; + +describe('SourceViewer utils', () => { + beforeEach(() => setHTMLFixture(SOURCE_CODE_CONTENT_MOCK)); + + const findContent = () => document.querySelector('.content'); + + describe('calculateBlameOffset', () => { + it('returns an offset of zero if line number === 1', () => { + expect(calculateBlameOffset(1)).toBe('0px'); + }); + + it('calculates an offset for the blame component', () => { + const { offsetTop } = document.querySelector('#LC3'); + expect(calculateBlameOffset(3)).toBe(`${offsetTop}px`); + }); + }); + + describe('toggleBlameClasses', () => { + it('adds classes', () => { + toggleBlameClasses(BLAME_DATA_MOCK, true); + expect(findContent()).toMatchSnapshot(); + }); + + it('removes classes', () => { + toggleBlameClasses(BLAME_DATA_MOCK, false); + expect(findContent()).toMatchSnapshot(); + }); + }); +});