Commit 11c4547a authored by Phil Hughes's avatar Phil Hughes

Extension component for merge request widget

Adds an extension component and a way to render
extensions inside of the merge request widget.
Also adds docs to allow for others to easily add new
extensions in the future.
parent 0ce2464b
<script>
import { GlButton, GlLoadingIcon, GlIcon, GlLink, GlBadge, GlSafeHtmlDirective } from '@gitlab/ui';
import SmartVirtualList from '~/vue_shared/components/smart_virtual_list.vue';
import StatusIcon from '../mr_widget_status_icon.vue';
export const LOADING_STATES = {
collapsedLoading: 'collapsedLoading',
collapsedError: 'collapsedError',
expandedLoading: 'expandedLoading',
};
export default {
components: {
GlButton,
GlLoadingIcon,
GlIcon,
GlLink,
GlBadge,
SmartVirtualList,
StatusIcon,
},
directives: {
SafeHtml: GlSafeHtmlDirective,
},
data() {
return {
loadingState: LOADING_STATES.collapsedLoading,
collapsedData: null,
fullData: null,
isCollapsed: true,
};
},
computed: {
isLoadingSummary() {
return this.loadingState === LOADING_STATES.collapsedLoading;
},
isLoadingExpanded() {
return this.loadingState === LOADING_STATES.expandedLoading;
},
isCollapsible() {
if (this.isLoadingSummary) {
return false;
}
return true;
},
statusIconName() {
if (this.isLoadingSummary) {
return 'loading';
}
if (this.loadingState === LOADING_STATES.collapsedError) {
return 'warning';
}
return this.statusIcon(this.collapsedData);
},
},
watch: {
isCollapsed(newVal) {
if (!newVal) {
this.loadAllData();
} else {
this.loadingState = null;
}
},
},
mounted() {
this.fetchCollapsedData(this.$props)
.then(data => {
this.collapsedData = data;
this.loadingState = null;
})
.catch(e => {
this.loadingState = LOADING_STATES.collapsedError;
throw e;
});
},
methods: {
toggleCollapsed() {
this.isCollapsed = !this.isCollapsed;
},
loadAllData() {
if (this.fullData) return;
this.loadingState = LOADING_STATES.expandedLoading;
this.fetchFullData(this.$props)
.then(data => {
this.loadingState = null;
this.fullData = data;
})
.catch(e => {
this.loadingState = null;
throw e;
});
},
},
};
</script>
<template>
<section class="media-section mr-widget-border-top">
<div class="media gl-p-5">
<status-icon :status="statusIconName" class="align-self-center" />
<div class="media-body d-flex flex-align-self-center align-items-center">
<div class="code-text">
<template v-if="isLoadingSummary">
{{ __('Loading...') }}
</template>
<div v-else v-safe-html="summary(collapsedData)"></div>
</div>
<gl-button
v-if="isCollapsible"
size="small"
class="float-right align-self-center"
@click="toggleCollapsed"
>
{{ isCollapsed ? __('Expand') : __('Collapse') }}
</gl-button>
</div>
</div>
<div v-if="!isCollapsed" class="mr-widget-grouped-section">
<div v-if="isLoadingExpanded" class="report-block-container">
<gl-loading-icon inline /> {{ __('Loading...') }}
</div>
<smart-virtual-list
v-else-if="fullData"
:length="fullData.length"
:remain="20"
:size="32"
wtag="ul"
wclass="report-block-list"
class="report-block-container"
>
<li v-for="data in fullData" :key="data.id" class="d-flex align-items-center">
<div v-if="data.icon" :class="data.icon.class" class="d-flex">
<gl-icon :name="data.icon.name" :size="24" />
</div>
<div
class="gl-mt-2 gl-mb-2 align-content-around align-items-start flex-wrap align-self-center d-flex"
>
<div class="gl-mr-4">
{{ data.text }}
</div>
<div v-if="data.link">
<gl-link :href="data.link.href">{{ data.link.text }}</gl-link>
</div>
<gl-badge v-if="data.badge" :variant="data.badge.variant || 'info'">
{{ data.badge.text }}
</gl-badge>
</div>
</li>
</smart-virtual-list>
</div>
</section>
</template>
import { extensions } from './index';
export default {
props: {
mr: {
type: Object,
required: true,
},
},
render(h) {
return h(
'div',
{},
extensions.map(extension =>
h(extension, {
props: extensions[0].props.reduce(
(acc, key) => ({
...acc,
[key]: this.mr[key],
}),
{},
),
}),
),
);
},
};
import ExtensionBase from './base.vue';
// Holds all the currently registered extensions
export const extensions = [];
export const registerExtension = extension => {
// Pushes into the extenions array a dynamically created Vue component
// that gets exteneded from `base.vue`
extensions.push({
extends: ExtensionBase,
name: extension.name,
props: extension.props,
computed: {
...Object.keys(extension.computed).reduce(
(acc, computedKey) => ({
...acc,
// Making the computed property a method allows us to pass in arguments
// this allows for each computed property to recieve some data
[computedKey]() {
return extension.computed[computedKey];
},
}),
{},
),
},
methods: {
...extension.methods,
},
});
};
/* eslint-disable */
import issuesCollapsedQuery from './issues_collapsed.query.graphql';
import issuesQuery from './issues.query.graphql';
export default {
// Give the extension a name
// Make it easier to track in Vue dev tools
name: 'WidgetIssues',
// Add an array of props
// These then get mapped to values stored in the MR Widget store
props: ['targetProjectFullPath'],
// Add any extra computed props in here
computed: {
// Small summary text to be displayed in the collapsed state
// Receives the collapsed data as an argument
summary(count) {
return `<strong>${count}</strong> open issue`;
},
// Status icon to be used next to the summary text
// Receives the collapsed data as an argument
statusIcon(count) {
return count > 0 ? 'warning' : 'success';
},
},
methods: {
// Fetches the collapsed data
// Ideally, this request should return the smallest amount of data possible
// Receives an object of all the props passed in to the extension
fetchCollapsedData({ targetProjectFullPath }) {
return this.$apollo
.query({ query: issuesCollapsedQuery, variables: { projectPath: targetProjectFullPath } })
.then(({ data }) => data.project.issues.count);
},
// Fetches the full data when the extension is expanded
// Receives an object of all the props passed in to the extension
fetchFullData({ targetProjectFullPath }) {
return this.$apollo
.query({ query: issuesQuery, variables: { projectPath: targetProjectFullPath } })
.then(({ data }) => {
// Return some transformed data to be rendered in the expanded state
return data.project.issues.nodes.map(issue => ({
id: issue.id, // Required: The ID of the object
text: issue.title, // Required: The text to get used on each row
// Icon to get rendered on the side of each row
icon: {
// Required: Name maps to an icon in GitLabs SVG
name:
issue.state === 'closed' ? 'status_failed_borderless' : 'status_success_borderless',
// Optional: An extra class to be added to the icon for additional styling
class: issue.state === 'closed' ? 'text-danger' : 'text-success',
},
// Badges get rendered next to the text on each row
badge: issue.state === 'closed' && {
text: 'Closed', // Required: Text to be used inside of the badge
// variant: 'info', // Optional: The variant of the badge, maps to GitLab UI variants
},
// Each row can have its own link that will take the user elsewhere
// link: {
// href: 'https://google.com', // Required: href for the link
// text: 'Link text', // Required: Text to be used inside the link
// },
}));
});
},
},
};
query getAllIssues($projectPath: ID!) {
project(fullPath: $projectPath) {
issues {
nodes {
id
title
webPath
webUrl
state
}
}
}
}
query getIssues($projectPath: ID!) {
project(fullPath: $projectPath) {
issues {
count
}
}
}
......@@ -3,6 +3,8 @@ import MrWidgetOptions from 'ee_else_ce/vue_merge_request_widget/mr_widget_optio
import VueApollo from 'vue-apollo';
import Translate from '../vue_shared/translate';
import createDefaultClient from '~/lib/graphql';
import { registerExtension } from './components/extensions';
import issueExtension from './extensions/issues';
Vue.use(Translate);
Vue.use(VueApollo);
......@@ -17,6 +19,8 @@ export default () => {
gl.mrWidgetData.gitlabLogo = gon.gitlab_logo;
gl.mrWidgetData.defaultAvatarUrl = gon.default_avatar_url;
registerExtension(issueExtension);
const vm = new Vue({ ...MrWidgetOptions, apolloProvider });
window.gl.mrWidget = {
......
......@@ -37,6 +37,7 @@ import FailedToMerge from './components/states/mr_widget_failed_to_merge.vue';
import MrWidgetAutoMergeEnabled from './components/states/mr_widget_auto_merge_enabled.vue';
import AutoMergeFailed from './components/states/mr_widget_auto_merge_failed.vue';
import CheckingState from './components/states/mr_widget_checking.vue';
// import ExtensionsContainer from './components/extensions/container';
import eventHub from './event_hub';
import notify from '~/lib/utils/notify';
import SourceBranchRemovalStatus from './components/source_branch_removal_status.vue';
......@@ -57,6 +58,7 @@ export default {
},
components: {
Loading,
// ExtensionsContainer,
'mr-widget-header': WidgetHeader,
'mr-widget-suggest-pipeline': WidgetSuggestPipeline,
'mr-widget-merge-help': WidgetMergeHelp,
......@@ -454,6 +456,7 @@ export default {
:service="service"
/>
<div class="mr-section-container mr-widget-workflow">
<!-- <extensions-container :mr="mr" /> -->
<grouped-codequality-reports-app
v-if="shouldRenderCodeQuality"
:base-path="mr.codeclimate.base_path"
......
......@@ -3,3 +3,7 @@
- [DirtySubmit](dirty_submit.md)
Disable form submits until there are unsaved changes.
- [Merge Request widget extensions](widget_extensions.md)
Easily add extensions into the merge request widget
# Merge request widget extensions
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/44616) in GitLab 13.6.
## Summary
Extensions in the merge request widget allow for others team to quickly and easily add new features
into the widget that will match the existing design and interaction as other extensions.
## Usage
To use extensions you need to first create a new extension object that will be used to fetch the
data that will be rendered in the extension. See the example file in
app/assets/javascripts/vue_merge_request_widget/extensions/issues.js for a working example.
The basic object structure is as below:
```javascript
export default {
name: '',
props: [],
computed: {
summary() {},
statusIcon() {},
},
methods: {
fetchCollapsedData() {},
fetchFullData() {},
},
};
```
Following the same data structure allows each extension to follow the same registering structure
but allows for each extension to manage where it gets its own data from.
After creating this structure you need to register it. Registering the extension can happen at any
point _after_ the widget has been created.
To register a extension the following can be done:
```javascript
// Import the register method
import { registerExtension } from '~/vue_merge_request_widget/components/extensions';
// Import the new extension
import issueExtension from '~/vue_merge_request_widget/extensions/issues';
// Register the imported extension
registerExtension(issueExtension);
```
......@@ -13,8 +13,11 @@ import CEWidgetOptions from '~/vue_merge_request_widget/mr_widget_options.vue';
import MrWidgetGeoSecondaryNode from './components/states/mr_widget_secondary_geo_node.vue';
import MrWidgetPolicyViolation from './components/states/mr_widget_policy_violation.vue';
// import ExtensionsContainer from '~/vue_merge_request_widget/components/extensions/container';
export default {
components: {
// ExtensionsContainer,
MrWidgetLicenses,
MrWidgetGeoSecondaryNode,
MrWidgetPolicyViolation,
......@@ -262,6 +265,7 @@ export default {
:service="service"
/>
<div class="mr-section-container mr-widget-workflow">
<!-- <extensions-container :mr="mr" /> -->
<blocking-merge-requests-report :mr="mr" />
<grouped-codequality-reports-app
v-if="shouldRenderCodeQuality"
......
import { registerExtension, extensions } from '~/vue_merge_request_widget/components/extensions';
import ExtensionBase from '~/vue_merge_request_widget/components/extensions/base.vue';
describe('MR widget extension registering', () => {
it('registers a extension', () => {
registerExtension({
name: 'Test',
props: ['helloWorld'],
computed: {
test() {},
},
methods: {
test() {},
},
});
expect(extensions[0]).toEqual(
expect.objectContaining({
extends: ExtensionBase,
name: 'Test',
props: ['helloWorld'],
computed: {
test: expect.any(Function),
},
methods: {
test: expect.any(Function),
},
}),
);
});
});
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