Commit 385be9bd authored by Enrique Alcantara's avatar Enrique Alcantara Committed by Francisco Javier López

Defer wiki rendering

Load a Wiki Page’s content using an
asynchronous HTTP request to avoid
page timeouts. This page is behind a
feature flag
parent f4d8114c
import { mountApplications } from '~/pages/shared/wikis/show';
import { mountApplications as mountEditApplications } from '~/pages/shared/wikis/async_edit';
mountApplications();
mountEditApplications();
<script>
import { GlSkeletonLoader, GlSafeHtmlDirective, GlAlert } from '@gitlab/ui';
import createFlash from '~/flash';
import { __ } from '~/locale';
import axios from '~/lib/utils/axios_utils';
import { renderGFM } from '../render_gfm_facade';
export default {
components: {
GlSkeletonLoader,
GlAlert,
},
directives: {
SafeHtml: GlSafeHtmlDirective,
},
props: {
getWikiContentUrl: {
type: String,
required: true,
},
},
data() {
return {
isLoadingContent: false,
loadingContentFailed: false,
content: null,
};
},
mounted() {
this.loadWikiContent();
},
methods: {
async loadWikiContent() {
this.loadingContentFailed = false;
this.isLoadingContent = true;
try {
const {
data: { content },
} = await axios.get(this.getWikiContentUrl, { params: { render_html: true } });
this.content = content;
this.$nextTick()
.then(() => {
renderGFM(this.$refs.content);
})
.catch(() =>
createFlash({
message: __('The content for this wiki page failed to render.'),
}),
);
} catch (e) {
this.loadingContentFailed = true;
} finally {
this.isLoadingContent = false;
}
},
},
i18n: {
loadingContentFailed: __(
'The content for this wiki page failed to load. To fix this error, reload the page.',
),
retryLoadingContent: __('Retry'),
},
};
</script>
<template>
<gl-skeleton-loader v-if="isLoadingContent" :width="830" :height="113">
<rect width="540" height="16" rx="4" />
<rect y="49" width="701" height="16" rx="4" />
<rect y="24" width="830" height="16" rx="4" />
<rect y="73" width="540" height="16" rx="4" />
</gl-skeleton-loader>
<gl-alert
v-else-if="loadingContentFailed"
:dismissible="false"
variant="danger"
:primary-button-text="$options.i18n.retryLoadingContent"
@primaryAction="loadWikiContent"
>
{{ $options.i18n.loadingContentFailed }}
</gl-alert>
<div
v-else-if="!loadingContentFailed && !isLoadingContent"
ref="content"
data-qa-selector="wiki_page_content"
data-testid="wiki_page_content"
class="js-wiki-page-content md"
v-html="content /* eslint-disable-line vue/no-v-html */"
></div>
</template>
import $ from 'jquery';
export const renderGFM = (el) => {
return $(el).renderGFM();
};
import Vue from 'vue';
import Wikis from './wikis';
import WikiContent from './components/wiki_content.vue';
const mountWikiContentApp = () => {
const el = document.querySelector('.js-async-wiki-page-content');
if (el) {
const { getWikiContentUrl } = el.dataset;
// eslint-disable-next-line no-new
new Vue({
el,
render(createElement) {
return createElement(WikiContent, {
props: { getWikiContentUrl },
});
},
});
}
};
export const mountApplications = () => {
// eslint-disable-next-line no-new
new Wikis();
mountWikiContentApp();
};
......@@ -223,7 +223,7 @@ module WikiActions
def page
strong_memoize(:page) do
wiki.find_page(*page_params)
wiki.find_page(*page_params, load_content: load_content?)
end
end
......@@ -310,6 +310,12 @@ module WikiActions
def send_wiki_file_blob(wiki, file_blob)
send_blob(wiki.repository, file_blob)
end
def load_content?
return false if params[:action] == 'history'
!(params[:action] == 'show' && Feature.enabled?(:wiki_async_load, container, default_enabled: :yaml))
end
end
WikiActions.prepend_mod
......@@ -134,6 +134,16 @@ module WikiHelper
current_user&.can?(:admin_project, container) &&
!container.has_confluence?
end
def wiki_page_render_api_endpoint(page)
api_v4_projects_wikis_path(wiki_page_render_api_endpoint_params(page))
end
private
def wiki_page_render_api_endpoint_params(page)
{ id: page.container.id, slug: ERB::Util.url_encode(page.slug), params: { version: page.version.id } }
end
end
WikiHelper.prepend_mod_with('WikiHelper')
......@@ -26,6 +26,10 @@
%div
- if can?(current_user, :create_wiki, @wiki.container) && @page.latest? && @valid_encoding
= link_to sprite_icon('pencil', css_class: 'gl-icon'), wiki_page_path(@wiki, @page, action: :edit), title: 'Edit', role: "button", class: 'btn gl-button btn-icon btn-default js-wiki-edit', data: { qa_selector: 'edit_page_button', testid: 'wiki_edit_button' }
= render 'shared/wikis/wiki_content'
- if Feature.enabled?(:wiki_async_load, @wiki.container, default_enabled: :yaml)
.js-async-wiki-page-content.md.gl-pt-2{ data: { qa_selector: 'wiki_page_content', testid: 'wiki_page_content', tracking_context: wiki_page_tracking_context(@page).to_json, get_wiki_content_url: wiki_page_render_api_endpoint(@page) } }
- else
= render 'shared/wikis/wiki_content'
= render 'shared/wikis/sidebar'
---
name: wiki_async_load
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/82394
rollout_issue_url:
milestone: '14.9'
type: development
group: group::editor
default_enabled: false
import { mountApplications } from '~/pages/shared/wikis/show';
import { mountApplications as mountEditApplications } from '~/pages/shared/wikis/async_edit';
mountApplications();
mountEditApplications();
......@@ -13,5 +13,12 @@ module EE
super
end
end
override :wiki_page_render_api_endpoint
def wiki_page_render_api_endpoint(page)
return super if page.wiki.is_a?(ProjectWiki)
api_v4_groups_wikis_path(wiki_page_render_api_endpoint_params(page))
end
end
end
......@@ -14,14 +14,26 @@ RSpec.describe 'Group wikis', :js do
wiki.container.add_owner(user)
end
it_behaves_like 'User creates wiki page'
it_behaves_like 'User deletes wiki page'
it_behaves_like 'User previews wiki changes'
it_behaves_like 'User updates wiki page'
it_behaves_like 'User uses wiki shortcuts'
it_behaves_like 'User views AsciiDoc page with includes'
it_behaves_like 'User views a wiki page'
it_behaves_like 'User views wiki pages'
it_behaves_like 'User views wiki sidebar'
it_behaves_like 'User views Git access wiki page'
shared_examples 'wiki feature tests' do
it_behaves_like 'User creates wiki page'
it_behaves_like 'User deletes wiki page'
it_behaves_like 'User previews wiki changes'
it_behaves_like 'User updates wiki page'
it_behaves_like 'User uses wiki shortcuts'
it_behaves_like 'User views AsciiDoc page with includes'
it_behaves_like 'User views a wiki page'
it_behaves_like 'User views wiki pages'
it_behaves_like 'User views wiki sidebar'
it_behaves_like 'User views Git access wiki page'
end
it_behaves_like 'wiki feature tests'
context 'when feature flag :wiki_async_load is disabled' do
before do
stub_feature_flags(wiki_async_load: false)
end
it_behaves_like 'wiki feature tests'
end
end
......@@ -37131,6 +37131,12 @@ msgstr ""
msgid "The content editor may change the markdown formatting style of the document, which may not match your original markdown style."
msgstr ""
msgid "The content for this wiki page failed to load. To fix this error, reload the page."
msgstr ""
msgid "The content for this wiki page failed to render."
msgstr ""
msgid "The content of this page is not encoded in UTF-8. Edits can only be made via the Git repository."
msgstr ""
......
......@@ -56,8 +56,11 @@ module DeprecationToolkitEnv
# In this case, we recommend to add a silence together with an issue to patch or update
# the dependency causing the problem.
# See https://gitlab.com/gitlab-org/gitlab/-/commit/aea37f506bbe036378998916d374966c031bf347#note_647515736
#
# - lib/gitlab/lazy.rb: https://gitlab.com/gitlab-org/gitlab/-/issues/356367
def self.allowed_kwarg_warning_paths
%w[
lib/gitlab/lazy.rb
]
end
......
......@@ -8,14 +8,26 @@ RSpec.describe 'Project wikis', :js do
let(:wiki) { create(:project_wiki, user: user, project: project) }
let(:project) { create(:project, namespace: user.namespace, creator: user) }
it_behaves_like 'User creates wiki page'
it_behaves_like 'User deletes wiki page'
it_behaves_like 'User previews wiki changes'
it_behaves_like 'User updates wiki page'
it_behaves_like 'User uses wiki shortcuts'
it_behaves_like 'User views AsciiDoc page with includes'
it_behaves_like 'User views a wiki page'
it_behaves_like 'User views wiki pages'
it_behaves_like 'User views wiki sidebar'
it_behaves_like 'User views Git access wiki page'
shared_examples 'wiki feature tests' do
it_behaves_like 'User creates wiki page'
it_behaves_like 'User deletes wiki page'
it_behaves_like 'User previews wiki changes'
it_behaves_like 'User updates wiki page'
it_behaves_like 'User uses wiki shortcuts'
it_behaves_like 'User views AsciiDoc page with includes'
it_behaves_like 'User views a wiki page'
it_behaves_like 'User views wiki pages'
it_behaves_like 'User views wiki sidebar'
it_behaves_like 'User views Git access wiki page'
end
it_behaves_like 'wiki feature tests'
context 'when feature flag :wiki_async_load is disabled' do
before do
stub_feature_flags(wiki_async_load: false)
end
it_behaves_like 'wiki feature tests'
end
end
import { GlSkeletonLoader, GlAlert } from '@gitlab/ui';
import { nextTick } from 'vue';
import { shallowMount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import WikiContent from '~/pages/shared/wikis/components/wiki_content.vue';
import { renderGFM } from '~/pages/shared/wikis/render_gfm_facade';
import axios from '~/lib/utils/axios_utils';
import httpStatus from '~/lib/utils/http_status';
import waitForPromises from 'helpers/wait_for_promises';
jest.mock('~/pages/shared/wikis/render_gfm_facade');
describe('pages/shared/wikis/components/wiki_content', () => {
const PATH = '/test';
let wrapper;
let mock;
function buildWrapper(propsData = {}) {
wrapper = shallowMount(WikiContent, {
propsData: { getWikiContentUrl: PATH, ...propsData },
stubs: {
GlSkeletonLoader,
GlAlert,
},
});
}
beforeEach(() => {
mock = new MockAdapter(axios);
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
const findGlAlert = () => wrapper.findComponent(GlAlert);
const findGlSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader);
const findContent = () => wrapper.find('[data-testid="wiki_page_content"]');
describe('when loading content', () => {
beforeEach(() => {
buildWrapper();
});
it('renders skeleton loader', () => {
expect(findGlSkeletonLoader().exists()).toBe(true);
});
it('does not render content container or error alert', () => {
expect(findGlAlert().exists()).toBe(false);
expect(findContent().exists()).toBe(false);
});
});
describe('when content loads successfully', () => {
const content = 'content';
beforeEach(() => {
mock.onGet(PATH, { params: { render_html: true } }).replyOnce(httpStatus.OK, { content });
buildWrapper();
return waitForPromises();
});
it('renders content container', () => {
expect(findContent().text()).toBe(content);
});
it('does not render skeleton loader or error alert', () => {
expect(findGlAlert().exists()).toBe(false);
expect(findGlSkeletonLoader().exists()).toBe(false);
});
it('calls renderGFM after nextTick', async () => {
await nextTick();
expect(renderGFM).toHaveBeenCalledWith(wrapper.element);
});
});
describe('when loading content fails', () => {
beforeEach(() => {
mock.onGet(PATH).replyOnce(httpStatus.INTERNAL_SERVER_ERROR, '');
buildWrapper();
return waitForPromises();
});
it('renders error alert', () => {
expect(findGlAlert().exists()).toBe(true);
});
it('does not render skeleton loader or content container', () => {
expect(findContent().exists()).toBe(false);
expect(findGlSkeletonLoader().exists()).toBe(false);
});
});
});
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