Commit 2f8b2d23 authored by Nick Thomas's avatar Nick Thomas

Merge branch 'ph/196514/codeNavigationFrontend' into 'master'

Code navigation frontend components

See merge request gitlab-org/gitlab!23787
parents 5c651fbf abb4b08e
...@@ -45,6 +45,7 @@ const Api = { ...@@ -45,6 +45,7 @@ const Api = {
mergeRequestsPipeline: '/api/:version/projects/:id/merge_requests/:merge_request_iid/pipelines', mergeRequestsPipeline: '/api/:version/projects/:id/merge_requests/:merge_request_iid/pipelines',
adminStatisticsPath: '/api/:version/application/statistics', adminStatisticsPath: '/api/:version/application/statistics',
pipelineSinglePath: '/api/:version/projects/:id/pipelines/:pipeline_id', pipelineSinglePath: '/api/:version/projects/:id/pipelines/:pipeline_id',
lsifPath: '/api/:version/projects/:id/commits/:commit_id/lsif/info',
group(groupId, callback) { group(groupId, callback) {
const url = Api.buildUrl(Api.groupPath).replace(':id', groupId); const url = Api.buildUrl(Api.groupPath).replace(':id', groupId);
...@@ -457,6 +458,14 @@ const Api = { ...@@ -457,6 +458,14 @@ const Api = {
return axios.get(url); return axios.get(url);
}, },
lsifData(projectPath, commitId, path) {
const url = Api.buildUrl(this.lsifPath)
.replace(':id', encodeURIComponent(projectPath))
.replace(':commit_id', commitId);
return axios.get(url, { params: { path } });
},
buildUrl(url) { buildUrl(url) {
return joinPaths(gon.relative_url_root || '', url.replace(':version', gon.api_version)); return joinPaths(gon.relative_url_root || '', url.replace(':version', gon.api_version));
}, },
......
<script>
import { mapActions, mapState } from 'vuex';
import Popover from './popover.vue';
export default {
components: {
Popover,
},
computed: {
...mapState(['currentDefinition', 'currentDefinitionPosition']),
},
mounted() {
this.blobViewer = document.querySelector('.blob-viewer');
this.addGlobalEventListeners();
this.fetchData();
},
beforeDestroy() {
this.removeGlobalEventListeners();
},
methods: {
...mapActions(['fetchData', 'showDefinition']),
addGlobalEventListeners() {
if (this.blobViewer) {
this.blobViewer.addEventListener('click', this.showDefinition);
}
},
removeGlobalEventListeners() {
if (this.blobViewer) {
this.blobViewer.removeEventListener('click', this.showDefinition);
}
},
},
};
</script>
<template>
<popover
v-if="currentDefinition"
:position="currentDefinitionPosition"
:data="currentDefinition"
/>
</template>
<script>
import { GlButton } from '@gitlab/ui';
export default {
components: {
GlButton,
},
props: {
position: {
type: Object,
required: true,
},
data: {
type: Object,
required: true,
},
},
data() {
return {
offsetLeft: 0,
};
},
computed: {
positionStyles() {
return {
left: `${this.position.x - this.offsetLeft}px`,
top: `${this.position.y + this.position.height}px`,
};
},
},
watch: {
position: {
handler() {
this.$nextTick(() => this.updateOffsetLeft());
},
deep: true,
immediate: true,
},
},
methods: {
updateOffsetLeft() {
this.offsetLeft = Math.max(
0,
this.$el.offsetLeft + this.$el.offsetWidth - window.innerWidth + 20,
);
},
},
colorScheme: gon?.user_color_scheme,
};
</script>
<template>
<div
:style="positionStyles"
class="popover code-navigation-popover popover-font-size-normal gl-popover bs-popover-bottom show"
>
<div :style="{ left: `${offsetLeft}px` }" class="arrow"></div>
<div v-for="(hover, index) in data.hover" :key="index" class="border-bottom">
<pre
v-if="hover.language"
ref="code-output"
:class="$options.colorScheme"
class="border-0 bg-transparent m-0 code highlight"
v-html="hover.value"
></pre>
<p v-else ref="doc-output" class="p-3 m-0">
{{ hover.value }}
</p>
</div>
<div v-if="data.definition_url" class="popover-body">
<gl-button :href="data.definition_url" target="_blank" class="w-100" variant="default">
{{ __('Go to definition') }}
</gl-button>
</div>
</div>
</template>
import Vue from 'vue';
import Vuex from 'vuex';
import store from './store';
import App from './components/app.vue';
Vue.use(Vuex);
export default () => {
const el = document.getElementById('js-code-navigation');
store.dispatch('setInitialData', el.dataset);
return new Vue({
el,
store,
render(h) {
return h(App);
},
});
};
import api from '~/api';
import { __ } from '~/locale';
import createFlash from '~/flash';
import * as types from './mutation_types';
import { getCurrentHoverElement, setCurrentHoverElement, addInteractionClass } from '../utils';
export default {
setInitialData({ commit }, data) {
commit(types.SET_INITIAL_DATA, data);
},
requestDataError({ commit }) {
commit(types.REQUEST_DATA_ERROR);
createFlash(__('An error occurred loading code navigation'));
},
fetchData({ commit, dispatch, state }) {
commit(types.REQUEST_DATA);
api
.lsifData(state.projectPath, state.commitId, state.path)
.then(({ data }) => {
const normalizedData = data.reduce((acc, d) => {
if (d.hover) {
acc[`${d.start_line}:${d.start_char}`] = d;
addInteractionClass(d);
}
return acc;
}, {});
commit(types.REQUEST_DATA_SUCCESS, normalizedData);
})
.catch(() => dispatch('requestDataError'));
},
showDefinition({ commit, state }, { target: el }) {
let definition;
let position;
if (!state.data) return;
const isCurrentElementPopoverOpen = el.classList.contains('hll');
if (getCurrentHoverElement()) {
getCurrentHoverElement().classList.remove('hll');
}
if (el.classList.contains('js-code-navigation') && !isCurrentElementPopoverOpen) {
const { lineIndex, charIndex } = el.dataset;
position = {
x: el.offsetLeft,
y: el.offsetTop,
height: el.offsetHeight,
};
definition = state.data[`${lineIndex}:${charIndex}`];
el.classList.add('hll');
setCurrentHoverElement(el);
}
commit(types.SET_CURRENT_DEFINITION, { definition, position });
},
};
import Vuex from 'vuex';
import createState from './state';
import actions from './actions';
import mutations from './mutations';
export default new Vuex.Store({
actions,
mutations,
state: createState(),
});
export const SET_INITIAL_DATA = 'SET_INITIAL_DATA';
export const REQUEST_DATA = 'REQUEST_DATA';
export const REQUEST_DATA_SUCCESS = 'REQUEST_DATA_SUCCESS';
export const REQUEST_DATA_ERROR = 'REQUEST_DATA_ERROR';
export const SET_CURRENT_DEFINITION = 'SET_CURRENT_DEFINITION';
import * as types from './mutation_types';
export default {
[types.SET_INITIAL_DATA](state, { projectPath, commitId, blobPath }) {
state.projectPath = projectPath;
state.commitId = commitId;
state.blobPath = blobPath;
},
[types.REQUEST_DATA](state) {
state.loading = true;
},
[types.REQUEST_DATA_SUCCESS](state, data) {
state.loading = false;
state.data = data;
},
[types.REQUEST_DATA_ERROR](state) {
state.loading = false;
},
[types.SET_CURRENT_DEFINITION](state, { definition, position }) {
state.currentDefinition = definition;
state.currentDefinitionPosition = position;
},
};
export default () => ({
projectPath: null,
commitId: null,
blobPath: null,
loading: false,
data: null,
currentDefinition: null,
currentDefinitionPosition: null,
});
export const cachedData = new Map();
export const getCurrentHoverElement = () => cachedData.get('current');
export const setCurrentHoverElement = el => cachedData.set('current', el);
export const addInteractionClass = d => {
let charCount = 0;
const line = document.getElementById(`LC${d.start_line + 1}`);
const el = [...line.childNodes].find(({ textContent }) => {
if (charCount === d.start_char) return true;
charCount += textContent.length;
return false;
});
if (el) {
el.setAttribute('data-char-index', d.start_char);
el.setAttribute('data-line-index', d.start_line);
el.classList.add('cursor-pointer', 'code-navigation', 'js-code-navigation');
}
};
...@@ -30,4 +30,9 @@ document.addEventListener('DOMContentLoaded', () => { ...@@ -30,4 +30,9 @@ document.addEventListener('DOMContentLoaded', () => {
} }
GpgBadges.fetch(); GpgBadges.fetch();
if (gon.features?.codeNavigation) {
// eslint-disable-next-line promise/catch-or-return
import('~/code_navigation').then(m => m.default());
}
}); });
...@@ -499,3 +499,15 @@ span.idiff { ...@@ -499,3 +499,15 @@ span.idiff {
background-color: transparent; background-color: transparent;
border: transparent; border: transparent;
} }
.code-navigation {
border-bottom: 1px $gray-darkest dashed;
&:hover {
border-bottom-color: $almost-black;
}
}
.code-navigation-popover {
max-width: 450px;
}
...@@ -29,6 +29,10 @@ class Projects::BlobController < Projects::ApplicationController ...@@ -29,6 +29,10 @@ class Projects::BlobController < Projects::ApplicationController
before_action :validate_diff_params, only: :diff before_action :validate_diff_params, only: :diff
before_action :set_last_commit_sha, only: [:edit, :update] before_action :set_last_commit_sha, only: [:edit, :update]
before_action only: :show do
push_frontend_feature_flag(:code_navigation, @project)
end
def new def new
commit unless @repository.empty? commit unless @repository.empty?
end end
......
...@@ -706,6 +706,10 @@ module ProjectsHelper ...@@ -706,6 +706,10 @@ module ProjectsHelper
Feature.enabled?(:vue_file_list, @project) Feature.enabled?(:vue_file_list, @project)
end end
def native_code_navigation_enabled?(project)
Feature.enabled?(:code_navigation, project)
end
def show_visibility_confirm_modal?(project) def show_visibility_confirm_modal?(project)
project.unlink_forks_upon_visibility_decrease_enabled? && project.visibility_level > Gitlab::VisibilityLevel::PRIVATE && project.forks_count > 0 project.unlink_forks_upon_visibility_decrease_enabled? && project.visibility_level > Gitlab::VisibilityLevel::PRIVATE && project.forks_count > 0
end end
......
...@@ -9,6 +9,8 @@ ...@@ -9,6 +9,8 @@
= render "projects/blob/auxiliary_viewer", blob: blob = render "projects/blob/auxiliary_viewer", blob: blob
#blob-content-holder.blob-content-holder #blob-content-holder.blob-content-holder
- if native_code_navigation_enabled?(@project)
#js-code-navigation{ data: { commit_id: blob.commit_id, path: blob.path, project_path: @project.full_path } }
%article.file-holder %article.file-holder
= render 'projects/blob/header', blob: blob = render 'projects/blob/header', blob: blob
= render 'projects/blob/content', blob: blob = render 'projects/blob/content', blob: blob
...@@ -1670,6 +1670,9 @@ msgstr "" ...@@ -1670,6 +1670,9 @@ msgstr ""
msgid "An error occurred fetching the dropdown data." msgid "An error occurred fetching the dropdown data."
msgstr "" msgstr ""
msgid "An error occurred loading code navigation"
msgstr ""
msgid "An error occurred previewing the blob" msgid "An error occurred previewing the blob"
msgstr "" msgstr ""
...@@ -9297,6 +9300,9 @@ msgstr "" ...@@ -9297,6 +9300,9 @@ msgstr ""
msgid "Go to commits" msgid "Go to commits"
msgstr "" msgstr ""
msgid "Go to definition"
msgstr ""
msgid "Go to environments" msgid "Go to environments"
msgstr "" msgstr ""
......
...@@ -20,7 +20,7 @@ describe 'a maintainer edits files on a source-branch of an MR from a fork', :js ...@@ -20,7 +20,7 @@ describe 'a maintainer edits files on a source-branch of an MR from a fork', :js
end end
before do before do
stub_feature_flags(web_ide_default: false, single_mr_diff_view: false) stub_feature_flags(web_ide_default: false, single_mr_diff_view: false, code_navigation: false)
target_project.add_maintainer(user) target_project.add_maintainer(user)
sign_in(user) sign_in(user)
......
...@@ -13,6 +13,10 @@ describe 'File blob', :js do ...@@ -13,6 +13,10 @@ describe 'File blob', :js do
wait_for_requests wait_for_requests
end end
before do
stub_feature_flags(code_navigation: false)
end
context 'Ruby file' do context 'Ruby file' do
before do before do
visit_blob('files/ruby/popen.rb') visit_blob('files/ruby/popen.rb')
......
...@@ -69,6 +69,8 @@ describe 'Editing file blob', :js do ...@@ -69,6 +69,8 @@ describe 'Editing file blob', :js do
context 'from blob file path' do context 'from blob file path' do
before do before do
stub_feature_flags(code_navigation: false)
visit project_blob_path(project, tree_join(branch, file_path)) visit project_blob_path(project, tree_join(branch, file_path))
end end
......
...@@ -8,6 +8,7 @@ describe 'User creates blob in new project', :js do ...@@ -8,6 +8,7 @@ describe 'User creates blob in new project', :js do
shared_examples 'creating a file' do shared_examples 'creating a file' do
before do before do
stub_feature_flags(code_navigation: false)
sign_in(user) sign_in(user)
visit project_path(project) visit project_path(project)
end end
......
...@@ -14,7 +14,7 @@ describe 'Projects > Files > User creates files', :js do ...@@ -14,7 +14,7 @@ describe 'Projects > Files > User creates files', :js do
let(:user) { create(:user) } let(:user) { create(:user) }
before do before do
stub_feature_flags(web_ide_default: false) stub_feature_flags(web_ide_default: false, code_navigation: false)
project.add_maintainer(user) project.add_maintainer(user)
sign_in(user) sign_in(user)
......
...@@ -14,6 +14,8 @@ describe 'Projects > Files > User deletes files', :js do ...@@ -14,6 +14,8 @@ describe 'Projects > Files > User deletes files', :js do
let(:user) { create(:user) } let(:user) { create(:user) }
before do before do
stub_feature_flags(code_navigation: false)
sign_in(user) sign_in(user)
end end
......
...@@ -16,6 +16,8 @@ describe 'Projects > Files > User replaces files', :js do ...@@ -16,6 +16,8 @@ describe 'Projects > Files > User replaces files', :js do
let(:user) { create(:user) } let(:user) { create(:user) }
before do before do
stub_feature_flags(code_navigation: false)
sign_in(user) sign_in(user)
end end
......
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Code navigation popover component renders popover 1`] = `
<div
class="popover code-navigation-popover popover-font-size-normal gl-popover bs-popover-bottom show"
style="left: 0px; top: 0px;"
>
<div
class="arrow"
style="left: 0px;"
/>
<div
class="border-bottom"
>
<pre
class="border-0 bg-transparent m-0 code highlight"
>
console.log
</pre>
</div>
<div
class="popover-body"
>
<gl-button-stub
class="w-100"
href="http://test.com"
size="md"
target="_blank"
variant="default"
>
Go to definition
</gl-button-stub>
</div>
</div>
`;
import Vuex from 'vuex';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import createState from '~/code_navigation/store/state';
import App from '~/code_navigation/components/app.vue';
import Popover from '~/code_navigation/components/popover.vue';
const localVue = createLocalVue();
const fetchData = jest.fn();
const showDefinition = jest.fn();
let wrapper;
localVue.use(Vuex);
function factory(initialState = {}) {
const store = new Vuex.Store({
state: {
...createState(),
...initialState,
},
actions: {
fetchData,
showDefinition,
},
});
wrapper = shallowMount(App, { store, localVue });
}
describe('Code navigation app component', () => {
afterEach(() => {
wrapper.destroy();
});
it('fetches data on mount', () => {
factory();
expect(fetchData).toHaveBeenCalled();
});
it('hides popover when no definition set', () => {
factory();
expect(wrapper.find(Popover).exists()).toBe(false);
});
it('renders popover when definition set', () => {
factory({
currentDefinition: { hover: 'console' },
currentDefinitionPosition: { x: 0 },
});
expect(wrapper.find(Popover).exists()).toBe(true);
});
it('calls showDefinition when clicking blob viewer', () => {
setFixtures('<div class="blob-viewer"></div>');
factory();
document.querySelector('.blob-viewer').click();
expect(showDefinition).toHaveBeenCalled();
});
});
import { shallowMount } from '@vue/test-utils';
import Popover from '~/code_navigation/components/popover.vue';
const MOCK_CODE_DATA = Object.freeze({
hover: [
{
language: 'javascript',
value: 'console.log',
},
],
definition_url: 'http://test.com',
});
const MOCK_DOCS_DATA = Object.freeze({
hover: [
{
language: null,
value: 'console.log',
},
],
definition_url: 'http://test.com',
});
let wrapper;
function factory(position, data) {
wrapper = shallowMount(Popover, { propsData: { position, data } });
}
describe('Code navigation popover component', () => {
afterEach(() => {
wrapper.destroy();
});
it('renders popover', () => {
factory({ x: 0, y: 0, height: 0 }, MOCK_CODE_DATA);
expect(wrapper.element).toMatchSnapshot();
});
describe('code output', () => {
it('renders code output', () => {
factory({ x: 0, y: 0, height: 0 }, MOCK_CODE_DATA);
expect(wrapper.find({ ref: 'code-output' }).exists()).toBe(true);
expect(wrapper.find({ ref: 'doc-output' }).exists()).toBe(false);
});
});
describe('documentation output', () => {
it('renders code output', () => {
factory({ x: 0, y: 0, height: 0 }, MOCK_DOCS_DATA);
expect(wrapper.find({ ref: 'code-output' }).exists()).toBe(false);
expect(wrapper.find({ ref: 'doc-output' }).exists()).toBe(true);
});
});
});
import MockAdapter from 'axios-mock-adapter';
import testAction from 'helpers/vuex_action_helper';
import actions from '~/code_navigation/store/actions';
import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { setCurrentHoverElement, addInteractionClass } from '~/code_navigation/utils';
jest.mock('~/flash');
jest.mock('~/code_navigation/utils');
describe('Code navigation actions', () => {
describe('setInitialData', () => {
it('commits SET_INITIAL_DATA', done => {
testAction(
actions.setInitialData,
{ projectPath: 'test' },
{},
[{ type: 'SET_INITIAL_DATA', payload: { projectPath: 'test' } }],
[],
done,
);
});
});
describe('requestDataError', () => {
it('commits REQUEST_DATA_ERROR', () =>
testAction(actions.requestDataError, null, {}, [{ type: 'REQUEST_DATA_ERROR' }], []));
it('creates a flash message', () =>
testAction(actions.requestDataError, null, {}, [{ type: 'REQUEST_DATA_ERROR' }], []).then(
() => {
expect(createFlash).toHaveBeenCalled();
},
));
});
describe('fetchData', () => {
let mock;
const state = {
projectPath: 'gitlab-org/gitlab',
commitId: '123',
blobPath: 'index',
};
const apiUrl = '/api/1/projects/gitlab-org%2Fgitlab/commits/123/lsif/info';
beforeEach(() => {
window.gon = { api_version: '1' };
mock = new MockAdapter(axios);
});
afterEach(() => {
mock.restore();
});
describe('success', () => {
beforeEach(() => {
mock.onGet(apiUrl).replyOnce(200, [
{
start_line: 0,
start_char: 0,
hover: { value: '123' },
},
{
start_line: 1,
start_char: 0,
hover: null,
},
]);
});
it('commits REQUEST_DATA_SUCCESS with normalized data', done => {
testAction(
actions.fetchData,
null,
state,
[
{ type: 'REQUEST_DATA' },
{
type: 'REQUEST_DATA_SUCCESS',
payload: { '0:0': { start_line: 0, start_char: 0, hover: { value: '123' } } },
},
],
[],
done,
);
});
it('calls addInteractionClass with data', done => {
testAction(
actions.fetchData,
null,
state,
[
{ type: 'REQUEST_DATA' },
{
type: 'REQUEST_DATA_SUCCESS',
payload: { '0:0': { start_line: 0, start_char: 0, hover: { value: '123' } } },
},
],
[],
)
.then(() => {
expect(addInteractionClass).toHaveBeenCalledWith({
start_line: 0,
start_char: 0,
hover: { value: '123' },
});
})
.then(done)
.catch(done.fail);
});
});
describe('error', () => {
beforeEach(() => {
mock.onGet(apiUrl).replyOnce(500);
});
it('dispatches requestDataError', done => {
testAction(
actions.fetchData,
null,
state,
[{ type: 'REQUEST_DATA' }],
[{ type: 'requestDataError' }],
done,
);
});
});
});
describe('showDefinition', () => {
let target;
beforeEach(() => {
target = document.createElement('div');
});
it('returns early when no data exists', done => {
testAction(actions.showDefinition, { target }, {}, [], [], done);
});
it('commits SET_CURRENT_DEFINITION when target is not code navitation element', done => {
testAction(
actions.showDefinition,
{ target },
{ data: {} },
[
{
type: 'SET_CURRENT_DEFINITION',
payload: { definition: undefined, position: undefined },
},
],
[],
done,
);
});
it('commits SET_CURRENT_DEFINITION with LSIF data', done => {
target.classList.add('js-code-navigation');
target.setAttribute('data-line-index', '0');
target.setAttribute('data-char-index', '0');
testAction(
actions.showDefinition,
{ target },
{ data: { '0:0': { hover: 'test' } } },
[
{
type: 'SET_CURRENT_DEFINITION',
payload: { definition: { hover: 'test' }, position: { height: 0, x: 0, y: 0 } },
},
],
[],
done,
);
});
it('adds hll class to target element', () => {
target.classList.add('js-code-navigation');
target.setAttribute('data-line-index', '0');
target.setAttribute('data-char-index', '0');
return testAction(
actions.showDefinition,
{ target },
{ data: { '0:0': { hover: 'test' } } },
[
{
type: 'SET_CURRENT_DEFINITION',
payload: { definition: { hover: 'test' }, position: { height: 0, x: 0, y: 0 } },
},
],
[],
).then(() => {
expect(target.classList).toContain('hll');
});
});
it('caches current target element', () => {
target.classList.add('js-code-navigation');
target.setAttribute('data-line-index', '0');
target.setAttribute('data-char-index', '0');
return testAction(
actions.showDefinition,
{ target },
{ data: { '0:0': { hover: 'test' } } },
[
{
type: 'SET_CURRENT_DEFINITION',
payload: { definition: { hover: 'test' }, position: { height: 0, x: 0, y: 0 } },
},
],
[],
).then(() => {
expect(setCurrentHoverElement).toHaveBeenCalledWith(target);
});
});
});
});
import mutations from '~/code_navigation/store/mutations';
import createState from '~/code_navigation/store/state';
let state;
describe('Code navigation mutations', () => {
beforeEach(() => {
state = createState();
});
describe('SET_INITIAL_DATA', () => {
it('sets initial data', () => {
mutations.SET_INITIAL_DATA(state, {
projectPath: 'test',
commitId: '123',
blobPath: 'index.js',
});
expect(state.projectPath).toBe('test');
expect(state.commitId).toBe('123');
expect(state.blobPath).toBe('index.js');
});
});
describe('REQUEST_DATA', () => {
it('sets loading true', () => {
mutations.REQUEST_DATA(state);
expect(state.loading).toBe(true);
});
});
describe('REQUEST_DATA_SUCCESS', () => {
it('sets loading false', () => {
mutations.REQUEST_DATA_SUCCESS(state, ['test']);
expect(state.loading).toBe(false);
});
it('sets data', () => {
mutations.REQUEST_DATA_SUCCESS(state, ['test']);
expect(state.data).toEqual(['test']);
});
});
describe('REQUEST_DATA_ERROR', () => {
it('sets loading false', () => {
mutations.REQUEST_DATA_ERROR(state);
expect(state.loading).toBe(false);
});
});
describe('SET_CURRENT_DEFINITION', () => {
it('sets current definition and position', () => {
mutations.SET_CURRENT_DEFINITION(state, { definition: 'test', position: { x: 0 } });
expect(state.currentDefinition).toBe('test');
expect(state.currentDefinitionPosition).toEqual({ x: 0 });
});
});
});
import {
cachedData,
getCurrentHoverElement,
setCurrentHoverElement,
addInteractionClass,
} from '~/code_navigation/utils';
afterEach(() => {
if (cachedData.has('current')) {
cachedData.delete('current');
}
});
describe('getCurrentHoverElement', () => {
it.each`
value
${'test'}
${undefined}
`('it returns cached current key', ({ value }) => {
if (value) {
cachedData.set('current', value);
}
expect(getCurrentHoverElement()).toEqual(value);
});
});
describe('setCurrentHoverElement', () => {
it('sets cached current key', () => {
setCurrentHoverElement('test');
expect(getCurrentHoverElement()).toEqual('test');
});
});
describe('addInteractionClass', () => {
beforeEach(() => {
setFixtures(
'<div id="LC1"><span>console</span><span>.</span><span>log</span></div><div id="LC2"><span>function</span></div>',
);
});
it.each`
line | char | index
${0} | ${0} | ${0}
${0} | ${8} | ${2}
${1} | ${0} | ${0}
`(
'it sets code navigation attributes for line $line and character $char',
({ line, char, index }) => {
addInteractionClass({ start_line: line, start_char: char });
expect(document.querySelectorAll(`#LC${line + 1} span`)[index].classList).toContain(
'js-code-navigation',
);
},
);
});
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