360 lines
10 KiB
Vue
360 lines
10 KiB
Vue
<script>
|
|
import {
|
|
GlAlert,
|
|
GlButton,
|
|
GlModal,
|
|
GlIntersectionObserver,
|
|
GlIcon,
|
|
GlLink,
|
|
GlSprintf,
|
|
GlExperimentBadge,
|
|
} from '@gitlab/ui';
|
|
import { uniqueId } from 'lodash';
|
|
import { sha256 } from '~/lib/utils/text_utility';
|
|
import { __, sprintf } from '~/locale';
|
|
import CrudComponent from '~/vue_shared/components/crud_component.vue';
|
|
import { renderMarkdown } from '~/notes/utils';
|
|
import SafeHtml from '~/vue_shared/directives/safe_html';
|
|
import { InternalEvents } from '~/tracking';
|
|
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
|
|
import { copyGLQLNodeAsGFM } from '../../utils/copy_as_gfm';
|
|
import { executeAndPresentQuery, presentPreview, loadMore } from '../../core';
|
|
import Counter from '../../utils/counter';
|
|
import { eventHubByKey } from '../../utils/event_hub_factory';
|
|
import GlqlPagination from './pagination.vue';
|
|
import GlqlActions from './actions.vue';
|
|
import GlqlFootnote from './footnote.vue';
|
|
|
|
const MAX_GLQL_BLOCKS = 20;
|
|
|
|
export default {
|
|
name: 'GlqlFacade',
|
|
components: {
|
|
GlAlert,
|
|
GlButton,
|
|
GlModal,
|
|
GlIcon,
|
|
GlLink,
|
|
GlSprintf,
|
|
GlExperimentBadge,
|
|
GlIntersectionObserver,
|
|
CrudComponent,
|
|
GlqlPagination,
|
|
GlqlFootnote,
|
|
GlqlActions,
|
|
},
|
|
directives: {
|
|
SafeHtml,
|
|
},
|
|
mixins: [InternalEvents.mixin(), glFeatureFlagsMixin()],
|
|
inject: ['queryKey'],
|
|
props: {
|
|
query: {
|
|
required: true,
|
|
type: String,
|
|
},
|
|
},
|
|
data() {
|
|
return {
|
|
eventHub: eventHubByKey(this.queryKey),
|
|
crudComponentId: `glql-${this.queryKey}`,
|
|
|
|
queryModalSettings: {
|
|
id: uniqueId('glql-modal-'),
|
|
show: false,
|
|
title: '',
|
|
primaryAction: { text: __('Copy source') },
|
|
cancelAction: { text: __('Close') },
|
|
},
|
|
|
|
loadOnClick: true,
|
|
previewPresenter: null,
|
|
finalPresenter: null,
|
|
error: {
|
|
variant: 'warning',
|
|
title: null,
|
|
message: null,
|
|
action: null,
|
|
},
|
|
|
|
preClasses: 'code highlight code-syntax-highlight-theme',
|
|
|
|
isCollapsed: false,
|
|
};
|
|
},
|
|
computed: {
|
|
data() {
|
|
return this.finalPresenter?.data || {};
|
|
},
|
|
config() {
|
|
return this.finalPresenter?.config || this.previewPresenter?.config || {};
|
|
},
|
|
isPreview() {
|
|
return !this.finalPresenter;
|
|
},
|
|
title() {
|
|
return (
|
|
this.config.title ||
|
|
(this.config.display === 'table' ? __('Embedded table view') : __('Embedded list view'))
|
|
);
|
|
},
|
|
showEmptyState() {
|
|
return this.data.nodes?.length === 0 && !this.isPreview;
|
|
},
|
|
showCopyContentsAction() {
|
|
return Boolean(this.data.count) && !this.isCollapsed && !this.isPreview;
|
|
},
|
|
hasError() {
|
|
return this.error.title || this.error.message;
|
|
},
|
|
wrappedQuery() {
|
|
// eslint-disable-next-line @gitlab/require-i18n-strings
|
|
return `\`\`\`glql\n${this.query}\n\`\`\``;
|
|
},
|
|
},
|
|
watch: {
|
|
previewPresenter(previewPresenter) {
|
|
this.isCollapsed = previewPresenter?.config?.collapsed || false;
|
|
},
|
|
},
|
|
async mounted() {
|
|
this.loadOnClick = this.glFeatures.glqlLoadOnClick;
|
|
|
|
this.eventHub.$on('loadMore', this.loadMore.bind(this));
|
|
},
|
|
|
|
methods: {
|
|
viewSource({ title }) {
|
|
Object.assign(this.queryModalSettings, { title, show: true });
|
|
},
|
|
|
|
copySource() {
|
|
navigator.clipboard.writeText(this.wrappedQuery);
|
|
},
|
|
|
|
reload() {
|
|
this.reloadGlqlBlock();
|
|
},
|
|
|
|
async copyAsGFM() {
|
|
await copyGLQLNodeAsGFM(this.$refs.presenter.$el);
|
|
},
|
|
|
|
async loadMore() {
|
|
try {
|
|
const data = await loadMore(this.query, this.data.pageInfo.endCursor);
|
|
this.finalPresenter.data.pageInfo = data.pageInfo;
|
|
this.finalPresenter.data.nodes.push(...data.nodes);
|
|
|
|
this.eventHub.$emit('loadMoreComplete', this.finalPresenter.data);
|
|
} catch (error) {
|
|
this.handleQueryError(__('Unable to load the next page.'));
|
|
this.eventHub.$emit('loadMoreError');
|
|
}
|
|
},
|
|
|
|
loadGlqlBlock() {
|
|
if (this.finalPresenter || this.previewPresenter) return;
|
|
|
|
if (this.glFeatures.glqlLoadOnClick || this.checkGlqlBlocksCount()) {
|
|
this.loadPreviewPresenter();
|
|
this.loadFinalPresenter();
|
|
}
|
|
},
|
|
|
|
reloadGlqlBlock() {
|
|
this.finalPresenter = null;
|
|
this.previewPresenter = null;
|
|
|
|
this.dismissAlert();
|
|
this.loadPreviewPresenter();
|
|
this.loadFinalPresenter();
|
|
},
|
|
|
|
async loadFinalPresenter() {
|
|
try {
|
|
this.finalPresenter = await executeAndPresentQuery(this.query, this.queryKey);
|
|
this.trackRender();
|
|
} catch (error) {
|
|
switch (error.networkError?.statusCode) {
|
|
case 503:
|
|
this.handleTimeoutError();
|
|
break;
|
|
case 403:
|
|
this.handleForbiddenError();
|
|
break;
|
|
default:
|
|
this.handleQueryError(error.message);
|
|
}
|
|
}
|
|
},
|
|
|
|
async loadPreviewPresenter() {
|
|
this.dismissAlert();
|
|
|
|
try {
|
|
this.previewPresenter = await presentPreview(this.query, this.queryKey);
|
|
} catch (error) {
|
|
this.handleQueryError(error.message);
|
|
}
|
|
},
|
|
|
|
checkGlqlBlocksCount() {
|
|
try {
|
|
this.$options.numGlqlBlocks.increment();
|
|
return true;
|
|
} catch (e) {
|
|
this.handleLimitError();
|
|
return false;
|
|
}
|
|
},
|
|
|
|
handleQueryError(message) {
|
|
this.error = { ...this.$options.i18n.glqlDisplayError, message };
|
|
},
|
|
handleLimitError() {
|
|
this.error = this.$options.i18n.glqlLimitError;
|
|
},
|
|
handleTimeoutError() {
|
|
this.error = this.$options.i18n.glqlTimeoutError;
|
|
},
|
|
handleForbiddenError() {
|
|
this.error = this.$options.i18n.glqlForbiddenError;
|
|
},
|
|
dismissAlert() {
|
|
this.error = { variant: 'warning' };
|
|
},
|
|
renderMarkdown,
|
|
async trackRender() {
|
|
try {
|
|
this.trackEvent('render_glql_block', { label: await sha256(this.query) });
|
|
} catch (e) {
|
|
// ignore any tracking errors
|
|
}
|
|
},
|
|
},
|
|
safeHtmlConfig: { ALLOWED_TAGS: ['code'] },
|
|
i18n: {
|
|
glqlDisplayError: {
|
|
variant: 'warning',
|
|
title: __('An error occurred when trying to display this embedded view:'),
|
|
},
|
|
glqlLimitError: {
|
|
variant: 'warning',
|
|
title: sprintf(
|
|
__(
|
|
'Only %{n} embedded views can be automatically displayed on a page. Click the button below to manually display this block.',
|
|
),
|
|
{ n: MAX_GLQL_BLOCKS },
|
|
),
|
|
action: __('Display block'),
|
|
},
|
|
glqlTimeoutError: {
|
|
variant: 'warning',
|
|
title: sprintf(
|
|
__('Embedded view timed out. Add more filters to reduce the number of results.'),
|
|
{
|
|
n: MAX_GLQL_BLOCKS,
|
|
},
|
|
),
|
|
action: __('Retry'),
|
|
},
|
|
glqlForbiddenError: {
|
|
variant: 'danger',
|
|
title: __('Embedded view timed out. Try again later.'),
|
|
},
|
|
loadGlqlView: __('Load embedded view'),
|
|
},
|
|
numGlqlBlocks: new Counter(MAX_GLQL_BLOCKS),
|
|
};
|
|
</script>
|
|
<template>
|
|
<div data-testid="glql-facade">
|
|
<template v-if="hasError">
|
|
<gl-alert
|
|
:variant="error.variant"
|
|
class="!gl-my-3"
|
|
:primary-button-text="error.action"
|
|
@dismiss="dismissAlert"
|
|
@primaryAction="reloadGlqlBlock"
|
|
>
|
|
{{ error.title }}
|
|
<ul v-if="error.message" class="!gl-mb-0">
|
|
<li v-safe-html:[$options.safeHtmlConfig]="renderMarkdown(error.message)"></li>
|
|
</ul>
|
|
</gl-alert>
|
|
</template>
|
|
|
|
<div v-if="loadOnClick" class="markdown-code-block gl-relative">
|
|
<pre :class="preClasses"><gl-button
|
|
class="gl-font-regular gl-absolute gl-z-1 gl-top-2/4 gl-left-2/4"
|
|
style="transform: translate(-50%, -50%)"
|
|
:aria-label="$options.i18n.loadGlqlView"
|
|
@click="loadOnClick = false"
|
|
>{{ $options.i18n.loadGlqlView }}</gl-button><code class="gl-opacity-2">{{ query }}</code></pre>
|
|
</div>
|
|
<gl-intersection-observer v-else @appear="loadGlqlBlock">
|
|
<template v-if="finalPresenter || previewPresenter">
|
|
<crud-component
|
|
:anchor-id="crudComponentId"
|
|
:title="title"
|
|
:description="config.description"
|
|
:count="data.count"
|
|
is-collapsible
|
|
:collapsed="isCollapsed"
|
|
persist-collapsed-state
|
|
class="!gl-mt-5"
|
|
:body-class="{ '!gl-m-0 !gl-p-0': data.count || isPreview }"
|
|
@collapsed="isCollapsed = true"
|
|
@expanded="isCollapsed = false"
|
|
>
|
|
<template #actions>
|
|
<glql-actions
|
|
:show-copy-contents="showCopyContentsAction"
|
|
:modal-title="title"
|
|
@viewSource="viewSource"
|
|
@copySource="copySource"
|
|
@copyAsGFM="copyAsGFM"
|
|
@reload="reload"
|
|
/>
|
|
</template>
|
|
|
|
<component :is="finalPresenter.component" v-if="finalPresenter" ref="presenter" />
|
|
<component :is="previewPresenter.component" v-else-if="previewPresenter && !hasError" />
|
|
<div
|
|
v-if="data.count && data.nodes.length < data.count"
|
|
class="gl-border-t gl-border-section gl-p-3"
|
|
>
|
|
<glql-pagination :count="data.nodes.length" :total-count="data.count" />
|
|
</div>
|
|
|
|
<template v-if="showEmptyState" #empty>
|
|
{{ __('No data found for this query.') }}
|
|
</template>
|
|
|
|
<template #footer>
|
|
<glql-footnote />
|
|
</template>
|
|
</crud-component>
|
|
</template>
|
|
<div v-else-if="hasError" class="markdown-code-block gl-relative">
|
|
<pre :class="preClasses"><code>{{ query }}</code></pre>
|
|
</div>
|
|
</gl-intersection-observer>
|
|
<gl-modal
|
|
v-model="queryModalSettings.show"
|
|
:title="queryModalSettings.title"
|
|
:modal-id="queryModalSettings.id"
|
|
:action-primary="queryModalSettings.primaryAction"
|
|
:action-cancel="queryModalSettings.cancelAction"
|
|
@primary="copySource"
|
|
>
|
|
<div class="md">
|
|
<div class="markdown-code-block gl-relative">
|
|
<pre :class="preClasses"><code>{{ wrappedQuery }}</code></pre>
|
|
</div>
|
|
</div>
|
|
</gl-modal>
|
|
</div>
|
|
</template>
|