提交 b30a7fd3 编写于 作者: T Tomas Vik

fix(webview): highlighting labels (including scoped)

上级 18307069
import * as vscode from 'vscode';
import { GitLabNewService, GqlSnippet, GqlBlob } from '../gitlab/gitlab_new_service';
import { createGitService } from '../git_service_factory';
import { GqlSnippet, GqlBlob } from '../gitlab/gitlab_new_service';
import { createGitService, createGitLabNewService } from '../service_factory';
import { getCurrentWorkspaceFolderOrSelectOne } from '../services/workspace_service';
const pickSnippet = async (snippets: GqlSnippet[]) => {
......@@ -32,8 +32,7 @@ export const insertSnippet = async (): Promise<void> => {
return;
}
const gitService = createGitService(workspaceFolder);
const instanceUrl = await gitService.fetchCurrentInstanceUrl();
const gitLabService = new GitLabNewService(instanceUrl);
const gitLabService = await createGitLabNewService(workspaceFolder);
const remote = await gitService.fetchGitRemote();
if (!remote) {
throw new Error('Could not get parsed remote for your workspace');
......
import { TreeItem, TreeItemCollapsibleState, ThemeIcon, Uri } from 'vscode';
import { PROGRAMMATIC_COMMANDS } from '../../command_names';
import { GitLabNewService } from '../../gitlab/gitlab_new_service';
import { createGitService } from '../../git_service_factory';
import { createGitLabNewService } from '../../service_factory';
import { ChangedFileItem } from './changed_file_item';
export class MrItem extends TreeItem {
......@@ -29,9 +28,7 @@ export class MrItem extends TreeItem {
}
private async getChangedFiles(): Promise<TreeItem[]> {
const gitService = createGitService(this.project.uri);
const instanceUrl = await gitService.fetchCurrentInstanceUrl();
const gitlabService = new GitLabNewService(instanceUrl);
const gitlabService = await createGitLabNewService(this.project.uri);
const mrVersion = await gitlabService.getMrDiff(this.mr);
return mrVersion.diffs.map(d => new ChangedFileItem(this.mr, mrVersion, d, this.project));
}
......
......@@ -52,7 +52,7 @@ interface GqlNote {
body: string; // TODO: remove this once the SystemNote.vue doesn't require plain text body
bodyHtml: string;
}
export interface GqlDiscussion {
interface GqlDiscussion {
replyId: string;
createdAt: string;
notes: Node<GqlNote>;
......@@ -67,6 +67,19 @@ interface GqlDiscussionsProject {
};
}
interface RestLabelEvent {
label: unknown;
body: string;
// eslint-disable-next-line camelcase
created_at: string;
}
type Note = GqlDiscussion | RestLabelEvent;
function isLabelEvent(note: Note): note is RestLabelEvent {
return (note as RestLabelEvent).label !== undefined;
}
const queryGetSnippets = gql`
query GetSnippets($projectPath: ID!) {
project(fullPath: $projectPath) {
......@@ -253,7 +266,10 @@ export class GitLabNewService {
};
}
async getDiscussions(issuable: RestIssuable, endCursor?: string): Promise<GqlDiscussion[]> {
private async getDiscussions(
issuable: RestIssuable,
endCursor?: string,
): Promise<GqlDiscussion[]> {
const [projectPath] = issuable.references.full.split(/[#!]/);
const query = issuable.sha ? queryGetMrDiscussions : queryGetIssueDiscussions;
const result = await this.client.request<GqlProjectResult<GqlDiscussionsProject>>(query, {
......@@ -272,4 +288,30 @@ export class GitLabNewService {
}
return discussions.nodes.map(n => this.addHostToUrl(n));
}
private async getLabelEvents(issuable: RestIssuable): Promise<RestLabelEvent[]> {
const type = issuable.sha ? 'merge_requests' : 'issues';
const labelEventsUrl = `${this.instanceUrl}/api/v4/projects/${issuable.project_id}/${type}/${issuable.iid}/resource_label_events?sort=asc&per_page=100`;
const result = await crossFetch(labelEventsUrl, this.fetchOptions);
if (!result.ok) {
throw new FetchError(`Fetching file from ${labelEventsUrl} failed`, result);
}
return result.json();
}
async getDiscussionsAndLabelEvents(issuable: RestIssuable): Promise<Note[]> {
const [discussions, labelEvents] = await Promise.all([
this.getDiscussions(issuable),
this.getLabelEvents(issuable),
]);
const combinedEvents: Note[] = [...discussions, ...labelEvents];
combinedEvents.sort((a: Note, b: Note) => {
const aCreatedAt = isLabelEvent(a) ? a.created_at : a.createdAt;
const bCreatedAt = isLabelEvent(b) ? b.created_at : b.createdAt;
return aCreatedAt < bCreatedAt ? -1 : 1;
});
return combinedEvents;
}
}
......@@ -5,13 +5,12 @@ import { tokenService } from './services/token_service';
import { UserFriendlyError } from './errors/user_friendly_error';
import { ApiError } from './errors/api_error';
import { getCurrentWorkspaceFolder } from './services/workspace_service';
import { createGitService } from './git_service_factory';
import { createGitService } from './service_factory';
import { GitRemote } from './git/git_remote_parser';
import { handleError, logError } from './log';
import { getUserAgentHeader } from './utils/get_user_agent_header';
import { CustomQueryType } from './gitlab/custom_query_type';
import { CustomQuery } from './gitlab/custom_query';
import { GitLabNewService, GqlDiscussion } from './gitlab/gitlab_new_service';
interface GitLabProject {
id: number;
......@@ -482,35 +481,6 @@ export async function validateCIConfig(content: string) {
return validCIConfig;
}
interface LabelEvent {
label: unknown;
body: string;
// eslint-disable-next-line camelcase
created_at: string;
}
export async function fetchLabelEvents(issuable: RestIssuable): Promise<LabelEvent[]> {
let labelEvents: LabelEvent[] = [];
try {
const type = issuable.sha ? 'merge_requests' : 'issues';
const { response } = await fetch(
`/projects/${issuable.project_id}/${type}/${issuable.iid}/resource_label_events?sort=asc&per_page=100`,
);
labelEvents = response;
} catch (e) {
handleError(new UserFriendlyError('Failed to fetch label events for this issuable.', e));
}
labelEvents.forEach(el => {
// Temporarily disable eslint to be able to start enforcing stricter rules
// eslint-disable-next-line no-param-reassign
el.body = '';
});
return labelEvents;
}
interface Discussion {
notes: {
// eslint-disable-next-line camelcase
......@@ -566,30 +536,3 @@ export async function saveNote(params: {
return { success: false };
}
type note = GqlDiscussion | LabelEvent;
function isLabelEvent(object: any): object is LabelEvent {
return Boolean(object.label);
}
export async function fetchDiscussionsAndLabelEvents(issuable: RestIssuable): Promise<note[]> {
// obtaining GitLabNewService in GitLabService is temporary and will be removed in this or the next MR
const instanceUrl = await createGitService(
(await getCurrentWorkspaceFolder()) || '',
).fetchCurrentInstanceUrl();
const gitlabNewService = new GitLabNewService(instanceUrl);
const [discussions, labelEvents] = await Promise.all([
gitlabNewService.getDiscussions(issuable),
fetchLabelEvents(issuable),
]);
const combinedEvents: note[] = [...discussions, ...labelEvents];
combinedEvents.sort((a: note, b: note) => {
const aCreatedAt = isLabelEvent(a) ? a.created_at : a.createdAt;
const bCreatedAt = isLabelEvent(b) ? b.created_at : b.createdAt;
return aCreatedAt < bCreatedAt ? -1 : 1;
});
return combinedEvents;
}
const vscode = require('vscode');
const gitLabService = require('./gitlab_service');
const { getCurrentWorkspaceFolderOrSelectOne } = require('./services/workspace_service');
const { createGitService } = require('./git_service_factory');
const { createGitService } = require('./service_factory');
const { handleError } = require('./log');
const { VS_COMMANDS } = require('./command_names');
......
import * as vscode from 'vscode';
import { fromReviewUri } from './review_uri';
import { GitLabNewService } from '../gitlab/gitlab_new_service';
import { logError } from '../log';
import { createGitService } from '../git_service_factory';
import { createGitLabNewService } from '../service_factory';
export class ApiContentProvider implements vscode.TextDocumentContentProvider {
// eslint-disable-next-line class-methods-use-this
......@@ -13,8 +12,7 @@ export class ApiContentProvider implements vscode.TextDocumentContentProvider {
const params = fromReviewUri(uri);
if (!params.path || !params.commit) return '';
const instanceUrl = await createGitService(params.workspacePath).fetchCurrentInstanceUrl();
const service = new GitLabNewService(instanceUrl);
const service = await createGitLabNewService(params.workspacePath);
try {
return service.getFileContent(params.path, params.commit, params.projectId);
} catch (e) {
......
......@@ -2,7 +2,7 @@ const vscode = require('vscode');
const gitLabService = require('./gitlab_service');
const openers = require('./openers');
const { getCurrentWorkspaceFolderOrSelectOne } = require('./services/workspace_service');
const { createGitService } = require('./git_service_factory');
const { createGitService } = require('./service_factory');
const parseQuery = (query, noteableType) => {
const params = {};
......
......@@ -2,6 +2,7 @@ import * as vscode from 'vscode';
import { GitService } from './git_service';
import { tokenService } from './services/token_service';
import { log } from './log';
import { GitLabNewService } from './gitlab/gitlab_new_service';
export function createGitService(workspaceFolder: string): GitService {
const { instanceUrl, remoteName, pipelineGitRemoteName } = vscode.workspace.getConfiguration(
......@@ -18,3 +19,8 @@ export function createGitService(workspaceFolder: string): GitService {
log,
});
}
export async function createGitLabNewService(workspaceFolder: string): Promise<GitLabNewService> {
const gitService = createGitService(workspaceFolder);
return new GitLabNewService(await gitService.fetchCurrentInstanceUrl());
}
import { mount } from '@vue/test-utils';
import LabelNote from './LabelNote';
const addedLabelEvent = require('../../../../test/integration/fixtures/rest/label_events/added_normal.json');
const scopedLabelEvent = require('../../../../test/integration/fixtures/rest/label_events/added_scoped.json');
describe('LabelNote', () => {
let wrapper;
const tooltipDirective = jest.fn();
beforeEach(() => {
tooltipDirective.mockReset();
window.vsCodeApi = { postMessage: jest.fn() };
});
describe('added normal event', () => {
beforeEach(() => {
wrapper = mount(LabelNote, {
propsData: {
noteable: addedLabelEvent,
},
stubs: {
date: true,
},
directives: {
tooltip: tooltipDirective,
},
});
});
it('contains correct text', () => {
expect(wrapper.text()).toBe('Tomas Vik @viktomas added Category:Editor Extension label ·');
});
describe('label pill', () => {
it('has correct colors', () => {
expect(wrapper.find('.label-pill').attributes().style).toBe(
'background-color: rgb(66, 139, 202); color: rgb(255, 255, 255); border-color: #428bca;',
);
});
it('has correct tooltip', () => {
expect(tooltipDirective.mock.calls[0][1].value).toBe(
'Issues related to the Editor Extension category: https://about.gitlab.com/direction/create/editor_extension/',
);
});
});
});
describe('added scoped event', () => {
beforeEach(() => {
wrapper = mount(LabelNote, {
propsData: {
noteable: scopedLabelEvent,
},
stubs: {
date: true,
},
directives: {
tooltip: tooltipDirective,
},
});
});
it('contains correct text', () => {
expect(wrapper.text()).toBe('Tomas Vik @viktomas added group code review label ·');
});
describe('label pill', () => {
it('has correct colors', () => {
expect(wrapper.find('.label-pill').attributes().style).toBe(
'background-color: rgb(168, 214, 149); color: rgb(51, 51, 51); border-color: #a8d695;',
);
});
it('has scoped label with inverted colors inside', () => {
expect(wrapper.find('.scoped-pill').attributes().style).toBe(
'background-color: rgb(51, 51, 51); color: rgb(168, 214, 149);',
);
});
});
});
});
<script>
import NoteBody from './NoteBody';
import UserAvatar from './UserAvatar';
import icons from '../assets/icons';
import Date from './Date';
......@@ -12,26 +11,25 @@ export default {
},
},
components: {
NoteBody,
UserAvatar,
Date,
},
data: () => ({
icon: icons.label,
}),
computed: {
author() {
return this.noteable.user;
},
note() {
if (this.noteable.body === '') {
const action = this.noteable.action === 'add' ? 'added' : 'removed';
// FIXME: disabling rule to limit changes to production code when introducing eslint
// eslint-disable-next-line vue/no-side-effects-in-computed-properties
this.noteable.body = `${action} ~${this.noteable.label.name} label`;
}
return this.noteable;
action() {
return this.noteable.action === 'add' ? 'added' : 'removed';
},
labelName() {
return this.noteable.label.name.split('::')[0];
},
scopedName() {
return this.noteable.label.name.split('::')[1];
},
created() {
this.icon = icons.label;
},
};
</script>
......@@ -44,9 +42,30 @@ export default {
</div>
<div class="timelineContent">
<div class="note-header">
<user-avatar :user="author" :show-avatar="false" style="margin-right: 2px;" />
<note-body :note="note" style="margin-right: 2px;" /> ·
<date :date="noteable.created_at" style="margin-left: 2px;" />
<user-avatar :user="author" :show-avatar="false" />
<span class="label-action">{{ action }}</span>
<span
class="label-pill"
v-tooltip="noteable.label.description"
:style="{
backgroundColor: noteable.label.color,
color: noteable.label.text_color,
borderColor: noteable.label.color,
}"
>
<span class="label-name">{{ labelName }}</span>
<span
class="scoped-pill"
v-if="scopedName"
:style="{
backgroundColor: noteable.label.text_color,
color: noteable.label.color,
}"
>{{ scopedName }}</span
>
</span>
<span class="label-divider">label ·</span>
<date :date="noteable.created_at" />
</div>
</div>
</div>
......@@ -92,6 +111,34 @@ export default {
}
}
.note-header {
align-items: baseline;
.label-action,
.label-divider,
.label-pill {
margin-right: 3px;
}
}
.label-pill {
line-height: 1rem;
font-size: 0.75rem;
border-radius: 0.6rem; // slightly larger than the line-height because of the 1px border
border-width: 1px;
border-style: solid;
display: flex;
.label-name,
.scoped-pill {
padding: 0 3px;
}
.scoped-pill {
border-top-right-radius: 0.5rem;
border-bottom-right-radius: 0.5rem;
height: 100%;
}
}
ul {
list-style-type: disc;
padding-inline-start: 16px;
......
......@@ -3,6 +3,7 @@ const path = require('path');
const crypto = require('crypto');
const vscode = require('vscode');
const gitLabService = require('./gitlab_service');
const { createGitLabNewService } = require('./service_factory');
let context = null;
......@@ -84,8 +85,13 @@ const createMessageHandler = (panel, issuable, workspaceFolder) => async message
});
if (response.success !== false) {
const discussions = await gitLabService.fetchDiscussionsAndLabelEvents(issuable);
panel.webview.postMessage({ type: 'issuableFetch', issuable, discussions });
const gitlabNewService = await createGitLabNewService(workspaceFolder);
const discussionsAndLabels = await gitlabNewService.getDiscussionsAndLabelEvents(issuable);
panel.webview.postMessage({
type: 'issuableFetch',
issuable,
discussions: discussionsAndLabels,
});
panel.webview.postMessage({ type: 'noteSaved' });
} else {
panel.webview.postMessage({ type: 'noteSaved', status: false });
......@@ -93,7 +99,7 @@ const createMessageHandler = (panel, issuable, workspaceFolder) => async message
}
};
async function handleChangeViewState(panel, issuable) {
async function handleChangeViewState(panel, issuable, workspaceFolder) {
if (!panel.active) return;
const appReadyPromise = new Promise(resolve => {
......@@ -105,9 +111,10 @@ async function handleChangeViewState(panel, issuable) {
});
});
const discussonsAndLabels = await gitLabService.fetchDiscussionsAndLabelEvents(issuable);
const gitlabNewService = await createGitLabNewService(workspaceFolder);
const discussionsAndLabels = await gitlabNewService.getDiscussionsAndLabelEvents(issuable);
await appReadyPromise;
panel.webview.postMessage({ type: 'issuableFetch', issuable, discussions: discussonsAndLabels });
panel.webview.postMessage({ type: 'issuableFetch', issuable, discussions: discussionsAndLabels });
}
const getIconPathForIssuable = issuable => {
......@@ -130,7 +137,7 @@ async function create(issuable, workspaceFolder) {
panel.iconPath = getIconPathForIssuable(issuable);
panel.onDidChangeViewState(() => {
handleChangeViewState(panel, issuable);
handleChangeViewState(panel, issuable, workspaceFolder);
});
panel.webview.onDidReceiveMessage(createMessageHandler(panel, issuable, workspaceFolder));
......
{
"id": 77400724,
"user": {
"id": 3457201,
"name": "Tomas Vik",
"username": "viktomas",
"state": "active",
"avatar_url": "https://secure.gravatar.com/avatar/6042a9152ada74d9fb6a0cdce895337e?s=80&d=identicon",
"web_url": "https://gitlab.com/viktomas"
},
"created_at": "2020-12-11T13:13:20.192Z",
"resource_type": "MergeRequest",
"resource_id": 81385331,
"label": {
"id": 15126283,
"name": "Category:Editor Extension",
"color": "#428BCA",
"description": "Issues related to the Editor Extension category: https://about.gitlab.com/direction/create/editor_extension/",
"description_html": "Issues related to the Editor Extension category: <a href=\"https://about.gitlab.com/direction/create/editor_extension/\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">https://about.gitlab.com/direction/create/editor_extension/</a>",
"text_color": "#FFFFFF"
},
"action": "add"
}
{
"id": 77400728,
"user": {
"id": 3457201,
"name": "Tomas Vik",
"username": "viktomas",
"state": "active",
"avatar_url": "https://secure.gravatar.com/avatar/6042a9152ada74d9fb6a0cdce895337e?s=80&d=identicon",
"web_url": "https://gitlab.com/viktomas"
},
"created_at": "2020-12-11T13:13:20.192Z",
"resource_type": "MergeRequest",
"resource_id": 81385331,
"label": {
"id": 16934793,
"name": "group::code review",
"color": "#A8D695",
"description": "Issues belonging to the Code Review group of the Create stage of the DevOps lifecycle.",
"description_html": "Issues belonging to the Code Review group of the Create stage of the DevOps lifecycle.",
"text_color": "#333333"
},
"action": "add"
}
{
"id": 77400845,
"user": {
"id": 3457201,
"name": "Tomas Vik",
"username": "viktomas",
"state": "active",
"avatar_url": "https://secure.gravatar.com/avatar/6042a9152ada74d9fb6a0cdce895337e?s=80&d=identicon",
"web_url": "https://gitlab.com/viktomas"
},
"created_at": "2020-12-11T13:14:03.000Z",
"resource_type": "MergeRequest",
"resource_id": 81385331,
"label": {
"id": 1890178,
"name": "Accepting merge requests",
"color": "#69D100",
"description": "Issues opened for contribution from the Community. Issue's weight is an estimation of complexity. Please mention @gitlab-org/coaches if you have any questions :)",
"description_html": "Issues opened for contribution from the Community. Issue's weight is an estimation of complexity. Please mention <a href=\"/gitlab-org/coaches\" data-group=\"3952433\" data-reference-type=\"user\" data-container=\"body\" data-placement=\"top\" class=\"gfm gfm-project_member js-user-link\" title=\"GitLab.org / coaches\">@gitlab-org/coaches</a> if you have any questions :)",
"text_color": "#FFFFFF"
},
"action": "remove"
}
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册