Commit d25d1898 authored by Jose Ivan Vargas's avatar Jose Ivan Vargas

Merge branch '327384-diff-component' into 'master'

Diff component + utility functions

See merge request gitlab-org/gitlab!60187
parents aff5ebaf 0eb13627
......@@ -19,3 +19,18 @@ export const REPORT_COMPONENTS = Object.fromEntries(
component,
]),
);
/*
* Diff component
*/
const DIFF = 'diff';
const BEFORE = 'before';
const AFTER = 'after';
export const VIEW_TYPES = { DIFF, BEFORE, AFTER };
const NORMAL = 'normal';
const REMOVED = 'removed';
const ADDED = 'added';
export const LINE_TYPES = { NORMAL, REMOVED, ADDED };
<script>
import { GlButtonGroup, GlButton } from '@gitlab/ui';
import { VIEW_TYPES, LINE_TYPES } from './constants';
import { createDiffData } from './diff_utils';
export default {
components: {
GlButtonGroup,
GlButton,
},
props: {
before: {
type: String,
required: true,
},
after: {
type: String,
required: true,
},
},
data() {
return {
view: VIEW_TYPES.DIFF,
};
},
viewTypes: {
DIFF: VIEW_TYPES.DIFF,
BEFORE: VIEW_TYPES.BEFORE,
AFTER: VIEW_TYPES.AFTER,
},
computed: {
diffData() {
return createDiffData(this.before, this.after);
},
visibleDiffData() {
return this.diffData.filter(this.shouldShowLine);
},
isDiffView() {
return this.view === this.$options.viewTypes.DIFF;
},
isBeforeView() {
return this.view === this.$options.viewTypes.BEFORE;
},
isAfterView() {
return this.view === this.$options.viewTypes.AFTER;
},
},
methods: {
shouldShowLine(line) {
return (
this.view === VIEW_TYPES.DIFF ||
line.type === LINE_TYPES.NORMAL ||
(line.type === LINE_TYPES.REMOVED && this.isBeforeView) ||
(line.type === LINE_TYPES.ADDED && this.isAfterView)
);
},
changeClass(change) {
return {
normal: '',
added: 'new',
removed: 'old',
}[change.type];
},
changeClassIDiff(action) {
if (action.removed || action.added) {
return 'idiff';
}
return '';
},
setView(viewType) {
this.view = viewType;
},
},
userColorScheme: window.gon?.user_color_scheme,
};
</script>
<template>
<div class="gl-rounded-base gl-border-solid gl-border-1 gl-border-gray-100">
<div class="gl-overflow-hidden gl-border-b-solid gl-border-b-1 gl-border-b-gray-100 gl-p-3">
<gl-button-group class="gl-display-flex gl-float-right">
<gl-button
:class="{ selected: isDiffView }"
data-testid="diffButton"
@click="setView($options.viewTypes.DIFF)"
>
{{ s__('GenericReport|Diff') }}
</gl-button>
<gl-button
:class="{ selected: isBeforeView }"
data-testid="beforeButton"
@click="setView($options.viewTypes.BEFORE)"
>
{{ s__('GenericReport|Before') }}
</gl-button>
<gl-button
:class="{ selected: isAfterView }"
data-testid="afterButton"
@click="setView($options.viewTypes.AFTER)"
>
{{ s__('GenericReport|After') }}
</gl-button>
</gl-button-group>
</div>
<table class="code" :class="$options.userColorScheme">
<tr
v-for="(line, idx) in visibleDiffData"
:key="idx"
:class="changeClass(line)"
class="line_holder"
data-testid="diffLine"
>
<td class="diff-line-num gl-border-t-0 gl-border-b-0" :class="changeClass(line)">
{{ line.oldLine }}
</td>
<td class="diff-line-num gl-border-t-0 gl-border-b-0" :class="changeClass(line)">
{{ line.newLine }}
</td>
<td data-testid="diffContent" class="line_content" :class="changeClass(line)">
<span
v-for="(action, actionIdx) in line.actions"
:key="actionIdx"
class="left right"
:class="changeClassIDiff(action)"
>{{ action.value }}</span
>
</td>
</tr>
</table>
</div>
</template>
/**
* Use the function `createDiffData` to create diff lines for rendering
* diffs
*/
import { diffChars } from 'diff';
import { LINE_TYPES } from './constants';
export function splitAction(action) {
const splitValues = action.value.split(/(\n)/);
const isEmptyCharacter = splitValues[splitValues.length - 1] === '';
const isNewLine = splitValues[splitValues.length - 2] === '\n';
if (splitValues.length >= 2 && isEmptyCharacter && isNewLine) {
splitValues.pop();
}
return splitValues.map((splitValue) => ({
added: action.added,
removed: action.removed,
value: splitValue,
}));
}
function setLineNumbers(lines) {
let beforeLineNo = 1;
let afterLineNo = 1;
return lines.map((line) => {
const lineNumbers = {};
if (line.type === LINE_TYPES.NORMAL) {
lineNumbers.oldLine = beforeLineNo;
lineNumbers.newLine = afterLineNo;
beforeLineNo += 1;
afterLineNo += 1;
} else if (line.type === LINE_TYPES.REMOVED) {
lineNumbers.oldLine = beforeLineNo;
beforeLineNo += 1;
} else if (line.type === LINE_TYPES.ADDED) {
lineNumbers.newLine = afterLineNo;
afterLineNo += 1;
}
return { ...line, ...lineNumbers };
});
}
function splitLinesInline(lines) {
const res = [];
const createLine = (type) => {
return { type, actions: [], oldLine: undefined, newLine: undefined };
};
const hasNonEmpty = (line, type) => {
let typeCount = 0;
for (let i = 0; i < line.actions.length; i += 1) {
const action = line.actions[i];
if (action[type]) {
typeCount += 1;
if (action.value !== '') {
return true;
}
}
}
return typeCount === line.actions.length;
};
lines.forEach((line) => {
const beforeLine = createLine(LINE_TYPES.NORMAL);
let afterLine = createLine(LINE_TYPES.ADDED);
let totalNormal = 0;
let totalRemoved = 0;
let totalAdded = 0;
const maybeAddAfter = () => {
if (totalAdded > 0 && hasNonEmpty(afterLine, LINE_TYPES.ADDED)) {
res.push(afterLine);
}
};
line.actions.forEach((action) => {
if (!action.added && !action.removed) {
totalNormal += 1;
beforeLine.actions.push(action);
afterLine.actions.push(action);
} else if (action.removed) {
totalRemoved += 1;
beforeLine.actions.push(action);
beforeLine.type = LINE_TYPES.REMOVED;
} else if (action.added) {
splitAction(action).forEach((split) => {
if (split.value === '\n') {
maybeAddAfter();
totalAdded = 0;
afterLine = createLine(LINE_TYPES.ADDED);
} else {
totalAdded += 1;
afterLine.actions.push(split);
}
});
}
});
if (totalNormal > 0 || totalRemoved > 0) {
res.push(beforeLine);
}
maybeAddAfter();
});
return setLineNumbers(res);
}
export function groupActionsByLines(actions) {
const res = [];
let currLine = { actions: [] };
const newLine = () => {
res.push(currLine);
currLine = { actions: [] };
};
actions.forEach((action) => {
if (action.added) {
currLine.actions.push(action);
} else {
splitAction(action).forEach((split) => {
if (split.value === '\n') {
newLine();
} else {
currLine.actions.push(split);
}
});
}
});
if (currLine.actions.length > 0) {
newLine();
}
return res;
}
/**
* Create an array of line objects of the form
* {
* type: normal | added | removed,
* actions: [], // array of action objects (see below)
* newLine: undefined or number,
* oldLine: undefined or number,
* }
*
* Action objects have the form
*
* {
* added: true | false,
* removed: true | false,
* value: string
* }
*/
export function createDiffData(before, after) {
const opts = {
ignoreWhitespace: false,
newlineIsToken: true,
};
const actions = diffChars(before, after, opts);
const lines = groupActionsByLines(actions);
return splitLinesInline(lines);
}
import { mount } from '@vue/test-utils';
import Diff from 'ee/vulnerabilities/components/generic_report/types/diff.vue';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
const TEST_DATA = {
before: `beforeText`,
after: `afterText`,
};
describe('ee/vulnerabilities/components/generic_report/types/diff.vue', () => {
let wrapper;
const createWrapper = () => {
return extendedWrapper(
mount(Diff, {
propsData: {
...TEST_DATA,
},
}),
);
};
beforeEach(() => {
wrapper = createWrapper();
});
afterEach(() => {
wrapper.destroy();
});
const findButton = (type) => wrapper.findByTestId(`${type}Button`);
const findDiffOutput = () => wrapper.find('.code').text();
const findDiffLines = () => wrapper.findAllByTestId('diffLine');
describe.each`
viewType | expectedLines | includesBeforeText | includesAfterText
${'diff'} | ${2} | ${true} | ${true}
${'before'} | ${1} | ${true} | ${false}
${'after'} | ${1} | ${false} | ${true}
`(
'with "$viewType" selected',
({ viewType, expectedLines, includesBeforeText, includesAfterText }) => {
beforeEach(() => findButton(viewType).trigger('click'));
it(`shows $expectedLines`, () => {
expect(findDiffLines()).toHaveLength(expectedLines);
});
it(`${includesBeforeText ? 'includes' : 'does not include'} before text`, () => {
expect(findDiffOutput().includes(TEST_DATA.before)).toBe(includesBeforeText);
});
it(`${includesAfterText ? 'includes' : 'does not include'} after text`, () => {
expect(findDiffOutput().includes(TEST_DATA.after)).toBe(includesAfterText);
});
},
);
});
import { diffChars } from 'diff';
import { LINE_TYPES } from 'ee/vulnerabilities/components/generic_report/types/constants';
import {
groupActionsByLines,
createDiffData,
} from 'ee/vulnerabilities/components/generic_report/types/diff_utils';
function actionType(action) {
let type;
if (action.removed === undefined && action.added === undefined) {
type = LINE_TYPES.NORMAL;
} else if (action.removed) {
type = LINE_TYPES.REMOVED;
} else if (action.added) {
type = LINE_TYPES.ADDED;
}
return [type, action.value];
}
function checkLineActions(line, actionSpecs) {
const lineActions = line.actions.map((action) => actionType(action));
expect(lineActions).toEqual(actionSpecs);
expect(line.actions).toHaveLength(actionSpecs.length);
}
function checkLine(line, oldLine, newLine, lineType, actionSpecs) {
expect(line.type).toEqual(lineType);
expect(line.oldLine).toEqual(oldLine);
expect(line.newLine).toEqual(newLine);
checkLineActions(line, actionSpecs);
}
describe('Report Items Diff Utils', () => {
describe('groupActionsByLines', () => {
it('correctly groups single-line changes by lines', () => {
const before = 'hello world';
const after = 'HELLO world';
const actions = diffChars(before, after);
const lines = groupActionsByLines(actions);
expect(lines).toHaveLength(1);
checkLineActions(lines[0], [
[LINE_TYPES.REMOVED, 'hello'],
[LINE_TYPES.ADDED, 'HELLO'],
[LINE_TYPES.NORMAL, ' world'],
]);
});
it('correctly groups whole-line deletions by lines', () => {
const before = 'a\nb';
const after = 'b';
const actions = diffChars(before, after);
const lines = groupActionsByLines(actions);
expect(lines).toHaveLength(2);
checkLineActions(lines[0], [[LINE_TYPES.REMOVED, 'a']]);
checkLineActions(lines[1], [[LINE_TYPES.NORMAL, 'b']]);
});
it('correctly groups whole-line insertions by lines', () => {
const before = 'x\ny\nz';
const after = 'x\ny\ny\nz';
const actions = diffChars(before, after);
const lines = groupActionsByLines(actions);
expect(lines).toHaveLength(3);
checkLineActions(lines[0], [[LINE_TYPES.NORMAL, 'x']]);
checkLineActions(lines[1], [[LINE_TYPES.NORMAL, 'y']]);
checkLineActions(lines[2], [
[LINE_TYPES.ADDED, 'y\n'],
[LINE_TYPES.NORMAL, 'z'],
]);
});
it('correctly groups empty line deletions', () => {
const before = '\n\n';
const after = '\n';
const actions = diffChars(before, after);
const lines = groupActionsByLines(actions);
expect(lines).toHaveLength(2);
checkLineActions(lines[0], [[LINE_TYPES.NORMAL, '']]);
checkLineActions(lines[1], [[LINE_TYPES.REMOVED, '']]);
});
it('Correctly groups empty line additions', () => {
const before = '\n';
const after = '\n\n\n';
const actions = diffChars(before, after);
const lines = groupActionsByLines(actions);
expect(lines).toHaveLength(2);
checkLineActions(lines[0], [[LINE_TYPES.NORMAL, '']]);
checkLineActions(lines[1], [[LINE_TYPES.ADDED, '\n\n']]);
});
});
describe('createDiffData', () => {
it('correctly creates diff lines for single line changes', () => {
const before = 'hello world';
const after = 'HELLO world';
const lines = createDiffData(before, after);
expect(lines).toHaveLength(2);
expect(lines[0].type).toEqual(LINE_TYPES.REMOVED);
checkLine(lines[0], 1, undefined, LINE_TYPES.REMOVED, [
[LINE_TYPES.REMOVED, 'hello'],
[LINE_TYPES.NORMAL, ' world'],
]);
checkLine(lines[1], undefined, 1, LINE_TYPES.ADDED, [
[LINE_TYPES.ADDED, 'HELLO'],
[LINE_TYPES.NORMAL, ' world'],
]);
});
it('correctly creates diff lines for single line deletions', () => {
const before = 'a\nb';
const after = 'b';
const lines = createDiffData(before, after);
expect(lines).toHaveLength(2);
checkLine(lines[0], 1, undefined, LINE_TYPES.REMOVED, [[LINE_TYPES.REMOVED, 'a']]);
checkLine(lines[1], 2, 1, LINE_TYPES.NORMAL, [[LINE_TYPES.NORMAL, 'b']]);
});
it('correctly tracks line numbers for single-line additions', () => {
const before = 'x\ny\nz';
const after = 'x\ny\ny\nz';
const lines = createDiffData(before, after);
expect(lines).toHaveLength(4);
checkLine(lines[0], 1, 1, LINE_TYPES.NORMAL, [[LINE_TYPES.NORMAL, 'x']]);
checkLine(lines[1], 2, 2, LINE_TYPES.NORMAL, [[LINE_TYPES.NORMAL, 'y']]);
checkLine(lines[2], undefined, 3, LINE_TYPES.ADDED, [[LINE_TYPES.ADDED, 'y']]);
checkLine(lines[3], 3, 4, LINE_TYPES.NORMAL, [[LINE_TYPES.NORMAL, 'z']]);
});
it('correctly tracks line numbers for multi-line additions', () => {
const before = 'Hello there\nHello world\nhello again';
const after = 'Hello there\nHello World\nanew line\nhello again\nhello again';
const lines = createDiffData(before, after);
expect(lines).toHaveLength(6);
checkLine(lines[0], 1, 1, LINE_TYPES.NORMAL, [[LINE_TYPES.NORMAL, 'Hello there']]);
checkLine(lines[1], 2, undefined, LINE_TYPES.REMOVED, [
[LINE_TYPES.NORMAL, 'Hello '],
[LINE_TYPES.REMOVED, 'w'],
[LINE_TYPES.NORMAL, 'orld'],
]);
checkLine(lines[2], undefined, 2, LINE_TYPES.ADDED, [
[LINE_TYPES.NORMAL, 'Hello '],
[LINE_TYPES.ADDED, 'W'],
[LINE_TYPES.NORMAL, 'orld'],
]);
checkLine(lines[3], undefined, 3, LINE_TYPES.ADDED, [[LINE_TYPES.ADDED, 'anew line']]);
checkLine(lines[4], 3, 4, LINE_TYPES.NORMAL, [[LINE_TYPES.NORMAL, 'hello again']]);
checkLine(lines[5], undefined, 5, LINE_TYPES.ADDED, [[LINE_TYPES.ADDED, 'hello again']]);
});
it('correctly diffs empty line deletions', () => {
const before = '\n\n';
const after = '\n';
const lines = createDiffData(before, after);
expect(lines).toHaveLength(2);
checkLine(lines[0], 1, 1, LINE_TYPES.NORMAL, [[LINE_TYPES.NORMAL, '']]);
checkLine(lines[1], 2, undefined, LINE_TYPES.REMOVED, [[LINE_TYPES.REMOVED, '']]);
});
it('correctly diffs empty line additions', () => {
const before = '\n';
const after = '\n\n\n';
const lines = createDiffData(before, after);
expect(lines).toHaveLength(3);
checkLine(lines[0], 1, 1, LINE_TYPES.NORMAL, [[LINE_TYPES.NORMAL, '']]);
checkLine(lines[1], undefined, 2, LINE_TYPES.ADDED, [[LINE_TYPES.ADDED, '']]);
checkLine(lines[2], undefined, 3, LINE_TYPES.ADDED, [[LINE_TYPES.ADDED, '']]);
});
});
});
......@@ -14304,6 +14304,15 @@ msgstr ""
msgid "Generic package file size in bytes"
msgstr ""
msgid "GenericReport|After"
msgstr ""
msgid "GenericReport|Before"
msgstr ""
msgid "GenericReport|Diff"
msgstr ""
msgid "Geo"
msgstr ""
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment