Skip to content
Snippets Groups Projects
Commit eff8130f authored by Miguel Rincon's avatar Miguel Rincon
Browse files

Add tab wrapper to lazily mount content only once

This change adds a new component that mounts contents inside tabs
lazily, without dismoounting them.
parent 12f8692c
No related branches found
No related tags found
No related merge requests found
This commit is part of merge request !49704. Comments created here will be created in the context of that merge request.
<script>
import { GlTab } from '@gitlab/ui';
/**
* Wrapper of <gl-tab> to optionally lazily render this tab's content
* when its shown **without dismounting after its hidden**.
*
* Usage:
*
* API is the same as <gl-tab>, for example:
*
* <gl-tabs>
* <editor-tab title="Tab 1" :lazy="true">
* lazily mounted content (gets mounted if this is first tab)
* </editor-tab>
* <editor-tab title="Tab 2" :lazy="true">
* lazily mounted content
* </editor-tab>
* <editor-tab title="Tab 3">
* eagerly mounted content
* </editor-tab>
* </gl-tabs>
*
* Once the tab is selected it is permanently set as "not-lazy"
* so it's contents are not dismounted.
*
* lazy is "false" by default, as in <gl-tab>.
*/
export default {
components: {
GlTab,
// Use a small renderless component to know when the tab content mounts because:
// - gl-tab always gets mounted, even if lazy is `true`. See:
// https://github.com/bootstrap-vue/bootstrap-vue/blob/dev/src/components/tabs/tab.js#L180
// - we cannot listen to events on <slot />
MountSpy: {
render: () => null,
},
},
inheritAttrs: false,
props: {
lazy: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
isLazy: this.lazy,
};
},
methods: {
onContentMounted() {
// When a child is first mounted make the entire tab
// permanently mounted by setting 'lazy' to false.
this.isLazy = false;
},
},
};
</script>
<template>
<gl-tab :lazy="isLazy" v-bind="$attrs" v-on="$listeners">
<slot v-for="slot in Object.keys($slots)" :slot="slot" :name="slot"></slot>
<mount-spy @hook:mounted="onContentMounted" />
</gl-tab>
</template>
<script>
import { GlAlert, GlLoadingIcon, GlTab, GlTabs } from '@gitlab/ui';
import { GlAlert, GlLoadingIcon, GlTabs } from '@gitlab/ui';
import { __, s__, sprintf } from '~/locale';
import { mergeUrlParams, redirectTo, refreshCurrentPage } from '~/lib/utils/url_utility';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
......@@ -7,6 +7,7 @@ import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import PipelineGraph from '~/pipelines/components/pipeline_graph/pipeline_graph.vue';
import CommitForm from './components/commit/commit_form.vue';
import TextEditor from './components/text_editor.vue';
import EditorTab from './components/ui/editor_tab.vue';
import commitCiFileMutation from './graphql/mutations/commit_ci_file.mutation.graphql';
import getBlobContent from './graphql/queries/blob_content.graphql';
......@@ -25,9 +26,9 @@ const LOAD_FAILURE_UNKNOWN = 'LOAD_FAILURE_UNKNOWN';
export default {
components: {
CommitForm,
EditorTab,
GlAlert,
GlLoadingIcon,
GlTab,
GlTabs,
PipelineGraph,
TextEditor,
......@@ -62,8 +63,6 @@ export default {
ciConfigData: {},
content: '',
contentModel: '',
currentTabIndex: 0,
editorIsReady: false,
failureType: null,
failureReasons: [],
isSaving: false,
......@@ -120,9 +119,6 @@ export default {
isVisualizationTabLoading() {
return this.$apollo.queries.ciConfigData.loading;
},
isVisualizeTabActive() {
return this.currentTabIndex === 1;
},
defaultCommitMessage() {
return sprintf(this.$options.i18n.defaultCommitMessage, { sourcePath: this.ciConfigPath });
},
......@@ -264,22 +260,20 @@ export default {
<div class="gl-mt-4">
<gl-loading-icon v-if="isBlobContentLoading" size="lg" class="gl-m-3" />
<div v-else class="file-editor gl-mb-3">
<gl-tabs v-model="currentTabIndex">
<!-- editor should be mounted when its tab is visible, so the container has a size -->
<gl-tab :title="$options.i18n.tabEdit" :lazy="!editorIsReady">
<!-- editor should be mounted only once, when the tab is displayed -->
<text-editor v-model="contentModel" @editor-ready="editorIsReady = true" />
</gl-tab>
<gl-tab
<gl-tabs>
<editor-tab :lazy="true" :title="$options.i18n.tabEdit">
<text-editor v-model="contentModel" />
</editor-tab>
<editor-tab
v-if="glFeatures.ciConfigVisualizationTab"
:lazy="true"
:title="$options.i18n.tabGraph"
:lazy="!isVisualizeTabActive"
:title-link-attributes="{ 'data-testid': 'visualization-tab-btn' }"
data-testid="visualization-tab"
>
<gl-loading-icon v-if="isVisualizationTabLoading" size="lg" class="gl-m-3" />
<pipeline-graph v-else :pipeline-data="ciConfigData" />
</gl-tab>
</editor-tab>
</gl-tabs>
</div>
<commit-form
......
import { nextTick } from 'vue';
import { mount } from '@vue/test-utils';
import { GlTabs } from '@gitlab/ui';
import EditorTab from '~/pipeline_editor/components/ui/editor_tab.vue';
const mockContent1 = 'MOCK CONTENT 1';
const mockContent2 = 'MOCK CONTENT 2';
describe('~/pipeline_editor/components/ui/editor_tab.vue', () => {
let wrapper;
let mockChildMounted = jest.fn();
const MockChild = {
props: ['content'],
template: '<div>{{content}}</div>',
mounted() {
mockChildMounted(this.content);
},
};
const MockTabbedContent = {
components: {
EditorTab,
GlTabs,
MockChild,
},
template: `
<gl-tabs>
<editor-tab :title-link-attributes="{ 'data-testid': 'tab1-btn' }" :lazy="true">
<mock-child content="${mockContent1}"/>
</editor-tab>
<editor-tab :title-link-attributes="{ 'data-testid': 'tab2-btn' }" :lazy="true">
<mock-child content="${mockContent2}"/>
</editor-tab>
</gl-tabs>
`,
};
const createWrapper = () => {
wrapper = mount(MockTabbedContent);
};
beforeEach(() => {
mockChildMounted = jest.fn();
});
it('tabs are mounted lazily', async () => {
createWrapper();
expect(mockChildMounted).toHaveBeenCalledTimes(0);
});
it('first tab is only mounted after nextTick', async () => {
createWrapper();
await nextTick();
expect(mockChildMounted).toHaveBeenCalledTimes(1);
expect(mockChildMounted).toHaveBeenCalledWith(mockContent1);
});
describe('user interaction', () => {
const clickTab = async testid => {
wrapper.find(`[data-testid="${testid}"]`).trigger('click');
await nextTick();
};
beforeEach(() => {
createWrapper();
});
it('mounts a tab once after selecting it', async () => {
await clickTab('tab2-btn');
expect(mockChildMounted).toHaveBeenCalledTimes(2);
expect(mockChildMounted).toHaveBeenNthCalledWith(1, mockContent1);
expect(mockChildMounted).toHaveBeenNthCalledWith(2, mockContent2);
});
it('mounts each tab once after selecting each', async () => {
await clickTab('tab2-btn');
await clickTab('tab1-btn');
await clickTab('tab2-btn');
expect(mockChildMounted).toHaveBeenCalledTimes(2);
expect(mockChildMounted).toHaveBeenNthCalledWith(1, mockContent1);
expect(mockChildMounted).toHaveBeenNthCalledWith(2, mockContent2);
});
});
});
import { nextTick } from 'vue';
import { mount, shallowMount, createLocalVue } from '@vue/test-utils';
import {
GlAlert,
GlButton,
GlFormInput,
GlFormTextarea,
GlLoadingIcon,
GlTabs,
GlTab,
} from '@gitlab/ui';
import { GlAlert, GlButton, GlFormInput, GlFormTextarea, GlLoadingIcon, GlTabs } from '@gitlab/ui';
import waitForPromises from 'helpers/wait_for_promises';
import VueApollo from 'vue-apollo';
import createMockApollo from 'jest/helpers/mock_apollo_helper';
......@@ -27,6 +19,8 @@ import {
import CommitForm from '~/pipeline_editor/components/commit/commit_form.vue';
import getCiConfig from '~/pipeline_editor/graphql/queries/ci_config.graphql';
import EditorTab from '~/pipeline_editor/components/ui/editor_tab.vue';
import PipelineGraph from '~/pipelines/components/pipeline_graph/pipeline_graph.vue';
import PipelineEditorApp from '~/pipeline_editor/pipeline_editor_app.vue';
import TextEditor from '~/pipeline_editor/components/text_editor.vue';
......@@ -135,7 +129,7 @@ describe('~/pipeline_editor/pipeline_editor_app.vue', () => {
const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
const findAlert = () => wrapper.find(GlAlert);
const findBlobFailureAlert = () => wrapper.find(GlAlert);
const findTabAt = i => wrapper.findAll(GlTab).at(i);
const findTabAt = i => wrapper.findAll(EditorTab).at(i);
const findVisualizationTab = () => wrapper.find('[data-testid="visualization-tab"]');
const findTextEditor = () => wrapper.find(TextEditor);
const findCommitForm = () => wrapper.find(CommitForm);
......@@ -167,26 +161,22 @@ describe('~/pipeline_editor/pipeline_editor_app.vue', () => {
describe('tabs', () => {
describe('editor tab', () => {
beforeEach(() => {
createComponent();
});
it('displays editor only after the tab is mounted', async () => {
createComponent({ mountFn: mount });
it('displays the tab and its content', async () => {
expect(
findTabAt(0)
.find(TextEditor)
.exists(),
).toBe(true);
});
it('displays tab lazily, until editor is ready', async () => {
expect(findTabAt(0).attributes('lazy')).toBe('true');
findTextEditor().vm.$emit('editor-ready');
).toBe(false);
await nextTick();
expect(findTabAt(0).attributes('lazy')).toBe(undefined);
expect(
findTabAt(0)
.find(TextEditor)
.exists(),
).toBe(true);
});
});
......@@ -206,6 +196,29 @@ describe('~/pipeline_editor/pipeline_editor_app.vue', () => {
expect(findLoadingIcon().exists()).toBe(true);
expect(findPipelineGraph().exists()).toBe(false);
});
it('displays the graph only after the tab is mounted and selected', async () => {
createComponent({ mountFn: mount });
expect(
findTabAt(1)
.find(PipelineGraph)
.exists(),
).toBe(false);
await nextTick();
// Select visualization tab
wrapper.find('[data-testid="visualization-tab-btn"]').trigger('click');
await nextTick();
expect(
findTabAt(1)
.find(PipelineGraph)
.exists(),
).toBe(true);
});
});
describe('with feature flag off', () => {
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment