Commit 0bd45c56 authored by Olena Horal-Koretska's avatar Olena Horal-Koretska

Merge branch 'jivanvl-fix-collapsible-sections' into 'master'

Make collapsible sections infinitely nested

See merge request gitlab-org/gitlab!65496
parents a02bb29b fdc13baf
<script>
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { INFINITELY_NESTED_COLLAPSIBLE_SECTIONS_FF } from '../../constants';
import LogLine from './line.vue';
import LogLineHeader from './line_header.vue';
......@@ -7,7 +9,9 @@ export default {
components: {
LogLine,
LogLineHeader,
CollapsibleLogSection: () => import('./collapsible_section.vue'),
},
mixins: [glFeatureFlagsMixin()],
props: {
section: {
type: Object,
......@@ -22,6 +26,9 @@ export default {
badgeDuration() {
return this.section.line && this.section.line.section_duration;
},
infinitelyCollapsibleSectionsFlag() {
return this.glFeatures?.[INFINITELY_NESTED_COLLAPSIBLE_SECTIONS_FF];
},
},
methods: {
handleOnClickCollapsibleLine(section) {
......@@ -40,12 +47,26 @@ export default {
@toggleLine="handleOnClickCollapsibleLine(section)"
/>
<template v-if="!section.isClosed">
<log-line
v-for="line in section.lines"
:key="line.offset"
:line="line"
:path="traceEndpoint"
/>
<template v-if="infinitelyCollapsibleSectionsFlag">
<template v-for="line in section.lines">
<collapsible-log-section
v-if="line.isHeader"
:key="line.line.offset"
:section="line"
:trace-endpoint="traceEndpoint"
@onClickCollapsibleLine="handleOnClickCollapsibleLine"
/>
<log-line v-else :key="line.offset" :line="line" :path="traceEndpoint" />
</template>
</template>
<template v-else>
<log-line
v-for="line in section.lines"
:key="line.offset"
:line="line"
:path="traceEndpoint"
/>
</template>
</template>
</div>
</template>
<script>
import { INFINITELY_NESTED_COLLAPSIBLE_SECTIONS_FF } from '../../constants';
export default {
functional: true,
props: {
......@@ -14,7 +16,9 @@ export default {
render(h, { props }) {
const { lineNumber, path } = props;
const parsedLineNumber = lineNumber + 1;
const parsedLineNumber = gon.features?.[INFINITELY_NESTED_COLLAPSIBLE_SECTIONS_FF]
? lineNumber
: lineNumber + 1;
const lineId = `L${parsedLineNumber}`;
const lineHref = `${path}#${lineId}`;
......
......@@ -24,3 +24,5 @@ export const JOB_RETRY_FORWARD_DEPLOYMENT_MODAL = {
};
export const SUCCESS_STATUS = 'SUCCESS';
export const INFINITELY_NESTED_COLLAPSIBLE_SECTIONS_FF = 'infinitelyCollapsibleSections';
import Vue from 'vue';
import { INFINITELY_NESTED_COLLAPSIBLE_SECTIONS_FF } from '../constants';
import * as types from './mutation_types';
import { logLinesParser, updateIncrementalTrace } from './utils';
import { logLinesParser, logLinesParserLegacy, updateIncrementalTrace } from './utils';
export default {
[types.SET_JOB_ENDPOINT](state, endpoint) {
......@@ -20,12 +21,26 @@ export default {
},
[types.RECEIVE_TRACE_SUCCESS](state, log = {}) {
const infinitelyCollapsibleSectionsFlag =
gon.features?.[INFINITELY_NESTED_COLLAPSIBLE_SECTIONS_FF];
if (log.state) {
state.traceState = log.state;
}
if (log.append) {
state.trace = log.lines ? updateIncrementalTrace(log.lines, state.trace) : state.trace;
if (infinitelyCollapsibleSectionsFlag) {
if (log.lines) {
const parsedResult = logLinesParser(
log.lines,
state.auxiliaryPartialTraceHelpers,
state.trace,
);
state.trace = parsedResult.parsedLines;
state.auxiliaryPartialTraceHelpers = parsedResult.auxiliaryPartialTraceHelpers;
}
} else {
state.trace = log.lines ? updateIncrementalTrace(log.lines, state.trace) : state.trace;
}
state.traceSize += log.size;
} else {
......@@ -33,7 +48,14 @@ export default {
// the trace response will not have a defined
// html or size. We keep the old value otherwise these
// will be set to `null`
state.trace = log.lines ? logLinesParser(log.lines) : state.trace;
if (infinitelyCollapsibleSectionsFlag) {
const parsedResult = logLinesParser(log.lines);
state.trace = parsedResult.parsedLines;
state.auxiliaryPartialTraceHelpers = parsedResult.auxiliaryPartialTraceHelpers;
} else {
state.trace = log.lines ? logLinesParserLegacy(log.lines) : state.trace;
}
state.traceSize = log.size || state.traceSize;
}
......
......@@ -30,4 +30,7 @@ export default () => ({
selectedStage: '',
stages: [],
jobs: [],
// to parse partial logs
auxiliaryPartialTraceHelpers: {},
});
......@@ -104,7 +104,7 @@ export const getIncrementalLineNumber = (acc) => {
* @param Array accumulator
* @returns Array parsed log lines
*/
export const logLinesParser = (lines = [], accumulator = []) =>
export const logLinesParserLegacy = (lines = [], accumulator = []) =>
lines.reduce(
(acc, line, index) => {
const lineNumber = accumulator.length > 0 ? getIncrementalLineNumber(acc) : index;
......@@ -131,6 +131,77 @@ export const logLinesParser = (lines = [], accumulator = []) =>
[...accumulator],
);
export const logLinesParser = (lines = [], previousTraceState = {}, prevParsedLines = []) => {
let currentLine = previousTraceState?.prevLineCount ?? 0;
let currentHeader = previousTraceState?.currentHeader;
let isPreviousLineHeader = previousTraceState?.isPreviousLineHeader ?? false;
const parsedLines = prevParsedLines.length > 0 ? prevParsedLines : [];
const sectionsQueue = previousTraceState?.sectionsQueue ?? [];
for (let i = 0; i < lines.length; i += 1) {
const line = lines[i];
// First run we can use the current index, later runs we have to retrieve the last number of lines
currentLine = previousTraceState?.prevLineCount ? currentLine + 1 : i + 1;
if (line.section_header && !isPreviousLineHeader) {
// If there's no previous line header that means we're at the root of the log
isPreviousLineHeader = true;
parsedLines.push(parseHeaderLine(line, currentLine));
currentHeader = { index: parsedLines.length - 1 };
} else if (line.section_header && isPreviousLineHeader) {
// If there's a current section, we can't push to the parsedLines array
sectionsQueue.push(currentHeader);
currentHeader = parseHeaderLine(line, currentLine); // Let's parse the incoming header line
} else if (line.section && !line.section_duration) {
// We're inside a collapsible section and want to parse a standard line
if (currentHeader?.index) {
// If the current section header is only an index, add the line as part of the lines
// array of the current collapsible section
parsedLines[currentHeader.index].lines.push(parseLine(line, currentLine));
} else {
// Otherwise add it to the innermost collapsible section lines array
currentHeader.lines.push(parseLine(line, currentLine));
}
} else if (line.section && line.section_duration) {
// NOTE: This marks the end of a section_header
const previousSection = sectionsQueue.pop();
// Add the duration to section header
// If at the root, just push the end to the current parsedLine,
// otherwise, push it to the previous sections queue
if (currentHeader?.index) {
parsedLines[currentHeader.index].line.section_duration = line.section_duration;
isPreviousLineHeader = false;
currentHeader = null;
} else {
currentHeader.line.section_duration = line.section_duration;
if (previousSection && previousSection?.index) {
// Is the previous section on root?
parsedLines[previousSection.index].lines.push(currentHeader);
} else if (previousSection && !previousSection?.index) {
previousSection.lines.push(currentHeader);
}
currentHeader = previousSection;
}
} else {
parsedLines.push(parseLine(line, currentLine));
}
}
return {
parsedLines,
auxiliaryPartialTraceHelpers: {
isPreviousLineHeader,
currentHeader,
sectionsQueue,
prevLineCount: lines.length,
},
};
};
/**
* Finds the repeated offset, removes the old one
*
......@@ -177,5 +248,5 @@ export const findOffsetAndRemove = (newLog = [], oldParsed = []) => {
export const updateIncrementalTrace = (newLog = [], oldParsed = []) => {
const parsedLog = findOffsetAndRemove(newLog, oldParsed);
return logLinesParser(newLog, parsedLog);
return logLinesParserLegacy(newLog, parsedLog);
};
......@@ -17,6 +17,10 @@ class Projects::JobsController < Projects::ApplicationController
before_action :verify_proxy_request!, only: :proxy_websocket_authorize
before_action :push_jobs_table_vue, only: [:index]
before_action do
push_frontend_feature_flag(:infinitely_collapsible_sections, @project, default_enabled: :yaml)
end
layout 'project'
feature_category :continuous_integration
......
---
name: infinitely_collapsible_sections
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/65496
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/335297
milestone: '14.1'
type: development
group: group::pipeline execution
default_enabled: false
......@@ -24,6 +24,7 @@ describe('Job App', () => {
let store;
let wrapper;
let mock;
let origGon;
const initSettings = {
endpoint: `${TEST_HOST}jobs/123.json`,
......@@ -85,11 +86,17 @@ describe('Job App', () => {
beforeEach(() => {
mock = new MockAdapter(axios);
store = createStore();
origGon = window.gon;
window.gon = { features: { infinitelyCollapsibleSections: false } }; // NOTE: All of this passes with the feature flag
});
afterEach(() => {
wrapper.destroy();
mock.restore();
window.gon = origGon;
});
describe('while loading', () => {
......
......@@ -4,6 +4,7 @@ import { collapsibleSectionClosed, collapsibleSectionOpened } from './mock_data'
describe('Job Log Collapsible Section', () => {
let wrapper;
let origGon;
const traceEndpoint = 'jobs/335';
......@@ -18,8 +19,16 @@ describe('Job Log Collapsible Section', () => {
});
};
beforeEach(() => {
origGon = window.gon;
window.gon = { features: { infinitelyCollapsibleSections: false } }; // NOTE: This also works with true
});
afterEach(() => {
wrapper.destroy();
window.gon = origGon;
});
describe('with closed section', () => {
......
import { mount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
import Log from '~/jobs/components/log/log.vue';
import { logLinesParser } from '~/jobs/store/utils';
import { logLinesParserLegacy, logLinesParser } from '~/jobs/store/utils';
import { jobLog } from './mock_data';
describe('Job Log', () => {
......@@ -9,6 +9,7 @@ describe('Job Log', () => {
let actions;
let state;
let store;
let origGon;
const localVue = createLocalVue();
localVue.use(Vuex);
......@@ -25,8 +26,12 @@ describe('Job Log', () => {
toggleCollapsibleLine: () => {},
};
origGon = window.gon;
window.gon = { features: { infinitelyCollapsibleSections: false } };
state = {
trace: logLinesParser(jobLog),
trace: logLinesParserLegacy(jobLog),
traceEndpoint: 'jobs/id',
};
......@@ -40,6 +45,88 @@ describe('Job Log', () => {
afterEach(() => {
wrapper.destroy();
window.gon = origGon;
});
const findCollapsibleLine = () => wrapper.find('.collapsible-line');
describe('line numbers', () => {
it('renders a line number for each open line', () => {
expect(wrapper.find('#L1').text()).toBe('1');
expect(wrapper.find('#L2').text()).toBe('2');
expect(wrapper.find('#L3').text()).toBe('3');
});
it('links to the provided path and correct line number', () => {
expect(wrapper.find('#L1').attributes('href')).toBe(`${state.traceEndpoint}#L1`);
});
});
describe('collapsible sections', () => {
it('renders a clickable header section', () => {
expect(findCollapsibleLine().attributes('role')).toBe('button');
});
it('renders an icon with the open state', () => {
expect(findCollapsibleLine().find('[data-testid="angle-down-icon"]').exists()).toBe(true);
});
describe('on click header section', () => {
it('calls toggleCollapsibleLine', () => {
jest.spyOn(wrapper.vm, 'toggleCollapsibleLine');
findCollapsibleLine().trigger('click');
expect(wrapper.vm.toggleCollapsibleLine).toHaveBeenCalled();
});
});
});
});
describe('Job Log, infinitelyCollapsibleSections feature flag enabled', () => {
let wrapper;
let actions;
let state;
let store;
let origGon;
const localVue = createLocalVue();
localVue.use(Vuex);
const createComponent = () => {
wrapper = mount(Log, {
localVue,
store,
});
};
beforeEach(() => {
actions = {
toggleCollapsibleLine: () => {},
};
origGon = window.gon;
window.gon = { features: { infinitelyCollapsibleSections: true } };
state = {
trace: logLinesParser(jobLog).parsedLines,
traceEndpoint: 'jobs/id',
};
store = new Vuex.Store({
actions,
state,
});
createComponent();
});
afterEach(() => {
wrapper.destroy();
window.gon = origGon;
});
const findCollapsibleLine = () => wrapper.find('.collapsible-line');
......
......@@ -58,6 +58,71 @@ export const utilsMockData = [
},
];
export const multipleCollapsibleSectionsMockData = [
{
offset: 1001,
content: [{ text: ' on docker-auto-scale-com 8a6210b8' }],
},
{
offset: 1002,
content: [
{
text: 'Executing "step_script" stage of the job script',
},
],
section: 'step-script',
section_header: true,
},
{
offset: 1003,
content: [{ text: 'sleep 60' }],
section: 'step-script',
},
{
offset: 1004,
content: [
{
text:
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam lorem dolor, congue ac condimentum vitae',
},
],
section: 'step-script',
},
{
offset: 1005,
content: [{ text: 'executing...' }],
section: 'step-script',
},
{
offset: 1006,
content: [{ text: '1st collapsible section' }],
section: 'collapsible-1',
section_header: true,
},
{
offset: 1007,
content: [
{
text:
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam lorem dolor, congue ac condimentum vitae',
},
],
section: 'collapsible-1',
},
{
offset: 1008,
content: [],
section: 'collapsible-1',
section_duration: '01:00',
},
{
offset: 1009,
content: [],
section: 'step-script',
section_duration: '10:00',
},
];
export const originalTrace = [
{
offset: 1,
......
......@@ -4,12 +4,21 @@ import state from '~/jobs/store/state';
describe('Jobs Store Mutations', () => {
let stateCopy;
let origGon;
const html =
'I, [2018-08-17T22:57:45.707325 #1841] INFO -- : Writing /builds/ab89e95b0fa0b9272ea0c797b76908f24d36992630e9325273a4ce3.png<br>I';
beforeEach(() => {
stateCopy = state();
origGon = window.gon;
window.gon = { features: { infinitelyCollapsibleSections: false } };
});
afterEach(() => {
window.gon = origGon;
});
describe('SET_JOB_ENDPOINT', () => {
......@@ -267,3 +276,88 @@ describe('Jobs Store Mutations', () => {
});
});
});
describe('Job Store mutations, feature flag ON', () => {
let stateCopy;
let origGon;
const html =
'I, [2018-08-17T22:57:45.707325 #1841] INFO -- : Writing /builds/ab89e95b0fa0b9272ea0c797b76908f24d36992630e9325273a4ce3.png<br>I';
beforeEach(() => {
stateCopy = state();
origGon = window.gon;
window.gon = { features: { infinitelyCollapsibleSections: true } };
});
afterEach(() => {
window.gon = origGon;
});
describe('RECEIVE_TRACE_SUCCESS', () => {
describe('with new job log', () => {
describe('log.lines', () => {
describe('when append is true', () => {
it('sets the parsed log ', () => {
mutations[types.RECEIVE_TRACE_SUCCESS](stateCopy, {
append: true,
size: 511846,
complete: true,
lines: [
{
offset: 1,
content: [{ text: 'Running with gitlab-runner 11.12.1 (5a147c92)' }],
},
],
});
expect(stateCopy.trace).toEqual([
{
offset: 1,
content: [{ text: 'Running with gitlab-runner 11.12.1 (5a147c92)' }],
lineNumber: 1,
},
]);
});
});
describe('when lines are defined', () => {
it('sets the parsed log ', () => {
mutations[types.RECEIVE_TRACE_SUCCESS](stateCopy, {
append: false,
size: 511846,
complete: true,
lines: [
{ offset: 0, content: [{ text: 'Running with gitlab-runner 11.11.1 (5a147c92)' }] },
],
});
expect(stateCopy.trace).toEqual([
{
offset: 0,
content: [{ text: 'Running with gitlab-runner 11.11.1 (5a147c92)' }],
lineNumber: 1,
},
]);
});
});
describe('when lines are null', () => {
it('sets the default value', () => {
mutations[types.RECEIVE_TRACE_SUCCESS](stateCopy, {
append: true,
html,
size: 511846,
complete: false,
lines: null,
});
expect(stateCopy.trace).toEqual([]);
});
});
});
});
});
});
import {
logLinesParser,
logLinesParserLegacy,
updateIncrementalTrace,
parseHeaderLine,
parseLine,
......@@ -17,6 +18,7 @@ import {
headerTraceIncremental,
collapsibleTrace,
collapsibleTraceIncremental,
multipleCollapsibleSectionsMockData,
} from '../components/log/mock_data';
describe('Jobs Store Utils', () => {
......@@ -175,11 +177,11 @@ describe('Jobs Store Utils', () => {
expect(isCollapsibleSection()).toEqual(false);
});
});
describe('logLinesParser', () => {
describe('logLinesParserLegacy', () => {
let result;
beforeEach(() => {
result = logLinesParser(utilsMockData);
result = logLinesParserLegacy(utilsMockData);
});
describe('regular line', () => {
......@@ -216,6 +218,87 @@ describe('Jobs Store Utils', () => {
});
});
describe('logLinesParser', () => {
let result;
beforeEach(() => {
result = logLinesParser(utilsMockData);
});
describe('regular line', () => {
it('adds a lineNumber property with correct index', () => {
expect(result.parsedLines[0].lineNumber).toEqual(1);
expect(result.parsedLines[1].line.lineNumber).toEqual(2);
});
});
describe('collapsible section', () => {
it('adds a `isClosed` property', () => {
expect(result.parsedLines[1].isClosed).toEqual(false);
});
it('adds a `isHeader` property', () => {
expect(result.parsedLines[1].isHeader).toEqual(true);
});
it('creates a lines array property with the content of the collapsible section', () => {
expect(result.parsedLines[1].lines.length).toEqual(2);
expect(result.parsedLines[1].lines[0].content).toEqual(utilsMockData[2].content);
expect(result.parsedLines[1].lines[1].content).toEqual(utilsMockData[3].content);
});
});
describe('section duration', () => {
it('adds the section information to the header section', () => {
expect(result.parsedLines[1].line.section_duration).toEqual(
utilsMockData[4].section_duration,
);
});
it('does not add section duration as a line', () => {
expect(result.parsedLines[1].lines.includes(utilsMockData[4])).toEqual(false);
});
});
describe('multiple collapsible sections', () => {
beforeEach(() => {
result = logLinesParser(multipleCollapsibleSectionsMockData);
});
it('should contain a section inside another section', () => {
const innerSection = [
{
isClosed: false,
isHeader: true,
line: {
content: [{ text: '1st collapsible section' }],
lineNumber: 6,
offset: 1006,
section: 'collapsible-1',
section_duration: '01:00',
section_header: true,
},
lines: [
{
content: [
{
text:
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam lorem dolor, congue ac condimentum vitae',
},
],
lineNumber: 7,
offset: 1007,
section: 'collapsible-1',
},
],
},
];
expect(result.parsedLines[1].lines).toEqual(expect.arrayContaining(innerSection));
});
});
});
describe('findOffsetAndRemove', () => {
describe('when last item is header', () => {
const existingLog = [
......@@ -391,7 +474,7 @@ describe('Jobs Store Utils', () => {
describe('updateIncrementalTrace', () => {
describe('without repeated section', () => {
it('concats and parses both arrays', () => {
const oldLog = logLinesParser(originalTrace);
const oldLog = logLinesParserLegacy(originalTrace);
const result = updateIncrementalTrace(regularIncremental, oldLog);
expect(result).toEqual([
......@@ -419,7 +502,7 @@ describe('Jobs Store Utils', () => {
describe('with regular line repeated offset', () => {
it('updates the last line and formats with the incremental part', () => {
const oldLog = logLinesParser(originalTrace);
const oldLog = logLinesParserLegacy(originalTrace);
const result = updateIncrementalTrace(regularIncrementalRepeated, oldLog);
expect(result).toEqual([
......@@ -438,7 +521,7 @@ describe('Jobs Store Utils', () => {
describe('with header line repeated', () => {
it('updates the header line and formats with the incremental part', () => {
const oldLog = logLinesParser(headerTrace);
const oldLog = logLinesParserLegacy(headerTrace);
const result = updateIncrementalTrace(headerTraceIncremental, oldLog);
expect(result).toEqual([
......@@ -464,7 +547,7 @@ describe('Jobs Store Utils', () => {
describe('with collapsible line repeated', () => {
it('updates the collapsible line and formats with the incremental part', () => {
const oldLog = logLinesParser(collapsibleTrace);
const oldLog = logLinesParserLegacy(collapsibleTrace);
const result = updateIncrementalTrace(collapsibleTraceIncremental, oldLog);
expect(result).toEqual([
......
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