Merge "Prepare bulk actions model to take RelatedChangeAndCommitInfo"
diff --git a/polygerrit-ui/app/api/rest-api.ts b/polygerrit-ui/app/api/rest-api.ts
index a7af005..3c06eb0 100644
--- a/polygerrit-ui/app/api/rest-api.ts
+++ b/polygerrit-ui/app/api/rest-api.ts
@@ -453,6 +453,9 @@
export declare interface CommentLinkInfo {
match: string;
link?: string;
+ prefix?: string;
+ suffix?: string;
+ text?: string;
enabled?: boolean;
html?: string;
}
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
index 76ec316..f2977dc 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
@@ -15,7 +15,7 @@
import '../../shared/gr-change-star/gr-change-star';
import '../../shared/gr-change-status/gr-change-status';
import '../../shared/gr-editable-content/gr-editable-content';
-import '../../shared/gr-formatted-text/gr-formatted-text';
+import '../../shared/gr-linked-text/gr-linked-text';
import '../../shared/gr-overlay/gr-overlay';
import '../../shared/gr-tooltip-content/gr-tooltip-content';
import '../gr-change-actions/gr-change-actions';
@@ -191,6 +191,7 @@
const MIN_LINES_FOR_COMMIT_COLLAPSE = 18;
+const REVIEWERS_REGEX = /^(R|CC)=/gm;
const MIN_CHECK_INTERVAL_SECS = 0;
const REPLY_REFIT_DEBOUNCE_INTERVAL_MS = 500;
@@ -958,7 +959,7 @@
/* Account for border and padding and rounding errors. */
max-width: calc(72ch + 2px + 2 * var(--spacing-m) + 0.4px);
}
- .commitMessage gr-formatted-text {
+ .commitMessage gr-linked-text {
word-break: break-word;
}
#commitMessageEditor {
@@ -1459,10 +1460,12 @@
.commitCollapsible=${this.computeCommitCollapsible()}
remove-zero-width-space=""
>
- <gr-formatted-text
- .markdown=${false}
- .content=${this.latestCommitMessage ?? ''}
- ></gr-formatted-text>
+ <gr-linked-text
+ pre=""
+ .content=${this.latestCommitMessage}
+ .config=${this.projectConfig?.commentlinks}
+ remove-zero-width-space=""
+ ></gr-linked-text>
</gr-editable-content>
</div>
<h3 class="assistive-tech-only">Comments and Checks Summary</h3>
@@ -1821,7 +1824,7 @@
return;
}
- this.latestCommitMessage = message;
+ this.latestCommitMessage = this.prepareCommitMsgForLinkify(message);
this.editingCommitMessage = false;
this.reloadWindow();
})
@@ -2671,6 +2674,14 @@
this.changeViewAriaHidden = true;
}
+ // Private but used in tests.
+ prepareCommitMsgForLinkify(msg: string) {
+ // TODO(wyatta) switch linkify sequence, see issue 5526.
+ // This is a zero-with space. It is added to prevent the linkify library
+ // from including R= or CC= as part of the email address.
+ return msg.replace(REVIEWERS_REGEX, '$1=\u200B');
+ }
+
/**
* Utility function to make the necessary modifications to a change in the
* case an edit exists.
@@ -2800,7 +2811,9 @@
throw new Error('Could not find latest Revision Sha');
const currentRevision = this.change.revisions[latestRevisionSha];
if (currentRevision.commit && currentRevision.commit.message) {
- this.latestCommitMessage = currentRevision.commit.message;
+ this.latestCommitMessage = this.prepareCommitMsgForLinkify(
+ currentRevision.commit.message
+ );
} else {
this.latestCommitMessage = null;
}
@@ -2853,7 +2866,9 @@
.getChangeCommitInfo(this.changeNum, lastpatchNum)
.then(commitInfo => {
if (!commitInfo) return;
- this.latestCommitMessage = commitInfo.message;
+ this.latestCommitMessage = this.prepareCommitMsgForLinkify(
+ commitInfo.message
+ );
});
}
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
index e0c09e2..ad84fb0 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
@@ -433,7 +433,9 @@
id="commitMessageEditor"
remove-zero-width-space=""
>
- <gr-formatted-text></gr-formatted-text>
+ <gr-linked-text pre="" remove-zero-width-space="">
+ <span id="output" slot="insert"></span>
+ </gr-linked-text>
</gr-editable-content>
</div>
<h3 class="assistive-tech-only">
@@ -1407,6 +1409,20 @@
assert.isTrue(overlayOpenStub.called);
});
+ test('prepareCommitMsgForLinkify', () => {
+ let commitMessage = 'R=test@google.com';
+ let result = element.prepareCommitMsgForLinkify(commitMessage);
+ assert.equal(result, 'R=\u200Btest@google.com');
+
+ commitMessage = 'R=test@google.com\nR=test@google.com';
+ result = element.prepareCommitMsgForLinkify(commitMessage);
+ assert.equal(result, 'R=\u200Btest@google.com\nR=\u200Btest@google.com');
+
+ commitMessage = 'CC=test@google.com';
+ result = element.prepareCommitMsgForLinkify(commitMessage);
+ assert.equal(result, 'CC=\u200Btest@google.com');
+ });
+
test('_isSubmitEnabled', () => {
assert.isFalse(element.isSubmitEnabled());
element.currentRevisionActions = {submit: {}};
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
index a0e4c89..731f227 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
@@ -882,7 +882,7 @@
override render() {
this.classList.toggle('editMode', this.editMode);
const patchChange = this.calculatePatchChange();
- return this.patched.html`
+ return html`
<h3 class="assistive-tech-only">File list</h3>
${this.renderContainer()} ${this.renderChangeTotals(patchChange)}
${this.renderBinaryTotals(patchChange)} ${this.renderControlRow()}
@@ -895,7 +895,7 @@
}
private renderContainer() {
- return this.patched.html`
+ return html`
<div
id="container"
@click=${(e: MouseEvent) => this.handleFileListClick(e)}
@@ -1013,7 +1013,7 @@
this.reportRenderedRow(index);
const previousFileName = this.shownFiles[index - 1]?.__path;
const patchSetFile = this.computePatchSetFile(file);
- return this.patched.html` <div class="stickyArea">
+ return html` <div class="stickyArea">
<div
class=${`file-row row ${this.computePathClass(file.__path)}`}
data-file=${JSON.stringify(patchSetFile)}
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
index b540c89..22f1325 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
@@ -257,9 +257,6 @@
@state() serverConfig?: ServerInfo;
@state()
- patchsetLevelDraftMessage = '';
-
- @state()
filterReviewerSuggestion: (input: Suggestion) => boolean;
@state()
@@ -382,7 +379,7 @@
patchsetLevelDraftIsResolved = true;
@state()
- patchsetLevelComment?: UnsavedInfo | DraftInfo;
+ patchsetLevelComment: UnsavedInfo | DraftInfo = this.createDraft('');
private readonly restApiService: RestApiService =
getAppContext().restApiService;
@@ -673,7 +670,9 @@
subscribe(
this,
() => this.getCommentsModel().patchsetLevelDrafts$,
- x => (this.patchsetLevelComment = x[0])
+ x => {
+ if (x.length > 0) this.patchsetLevelComment = x[0];
+ }
);
subscribe(
this,
@@ -764,7 +763,7 @@
changedProperties.has('mentionedUsersInUnresolvedDrafts') ||
changedProperties.has('includeComments') ||
changedProperties.has('labelsChanged') ||
- changedProperties.has('patchsetLevelDraftMessage') ||
+ changedProperties.has('patchsetLevelComment') ||
changedProperties.has('mentionedCCs')
) {
this.computeNewAttention();
@@ -916,10 +915,11 @@
}
// TODO: move to comment-util
- private createDraft(): UnsavedInfo {
+ // Private but used in tests.
+ createDraft(message: string): UnsavedInfo {
return {
patch_set: this.latestPatchNum,
- message: this.patchsetLevelDraftMessage,
+ message,
unresolved: !this.patchsetLevelDraftIsResolved,
path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
__unsaved: true,
@@ -927,8 +927,6 @@
}
private renderPatchsetLevelComment() {
- if (!this.patchsetLevelComment)
- this.patchsetLevelComment = this.createDraft();
return html`
<gr-comment
id="patchsetLevelComment"
@@ -938,7 +936,10 @@
this.patchsetLevelDraftIsResolved = !e.detail.value;
}}
@comment-text-changed=${(e: ValueChangedEvent<string>) => {
- this.patchsetLevelDraftMessage = e.detail.value;
+ const newMessage = e.detail.value;
+ if (this.patchsetLevelComment.message === newMessage) return;
+ this.patchsetLevelComment.message = newMessage;
+ this.requestUpdate('patchsetLevelComment');
}}
.messagePlaceholder=${this.messagePlaceholder}
hide-header
@@ -1268,7 +1269,7 @@
this.focusOn(focusTarget);
if (quote?.length) {
// If a reply quote has been provided, use it.
- this.patchsetLevelDraftMessage = quote;
+ this.patchsetLevelComment = this.createDraft(quote);
}
if (this.restApiService.hasPendingDiffDrafts()) {
this.savingComments = true;
@@ -1281,7 +1282,7 @@
hasDrafts() {
return (
- this.patchsetLevelDraftMessage.length > 0 ||
+ !!this.patchsetLevelComment.message?.length ||
this.draftCommentThreads.length > 0
);
}
@@ -1469,8 +1470,8 @@
return;
}
- this.patchsetLevelDraftMessage = '';
this.includeComments = true;
+ this.patchsetLevelComment = this.createDraft('');
this.dispatchEvent(
new CustomEvent('send', {
composed: true,
@@ -2031,7 +2032,6 @@
computeSendButtonDisabled() {
if (
this.canBeStarted === undefined ||
- this.patchsetLevelDraftMessage === undefined ||
this.reviewersMutated === undefined ||
this.labelsChanged === undefined ||
this.includeComments === undefined ||
@@ -2055,13 +2055,8 @@
const revotingOrNewVote = this.labelsChanged || existingVote;
const hasDrafts =
(this.includeComments && this.draftCommentThreads.length > 0) ||
- this.patchsetLevelDraftMessage.length > 0;
- return (
- !hasDrafts &&
- !this.patchsetLevelDraftMessage.length &&
- !this.reviewersMutated &&
- !revotingOrNewVote
- );
+ !!this.patchsetLevelComment?.message?.length;
+ return !hasDrafts && !this.reviewersMutated && !revotingOrNewVote;
}
computePatchSetWarning() {
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts
index acd1755..cef787a 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts
@@ -327,7 +327,9 @@
// Async tick is needed because iron-selector content is distributed and
// distributed content requires an observer to be set up.
await element.updateComplete;
- element.patchsetLevelDraftMessage = 'I wholeheartedly disapprove';
+ element.patchsetLevelComment = element.createDraft(
+ 'I wholeheartedly disapprove'
+ );
element.draftCommentThreads = [createCommentThread([createComment()])];
element.includeComments = true;
@@ -1054,7 +1056,9 @@
});
test('label picker', async () => {
- element.patchsetLevelDraftMessage = 'I wholeheartedly disapprove';
+ element.patchsetLevelComment = element.createDraft(
+ 'I wholeheartedly disapprove'
+ );
element.draftCommentThreads = [createCommentThread([createComment()])];
const saveReviewPromise = interceptSaveReview();
@@ -1075,7 +1079,7 @@
const review = await saveReviewPromise;
await element.updateComplete;
await waitUntil(() => element.disabled === false);
- assert.equal(element.patchsetLevelDraftMessage.length, 0);
+ assert.equal(element.patchsetLevelComment.message?.length, 0);
assert.deepEqual(review, {
drafts: 'PUBLISH_ALL_REVISIONS',
labels: {
@@ -1101,7 +1105,9 @@
// Async tick is needed because iron-selector content is distributed and
// distributed content requires an observer to be set up.
await element.updateComplete;
- element.patchsetLevelDraftMessage = 'I wholeheartedly disapprove';
+ element.patchsetLevelComment = element.createDraft(
+ 'I wholeheartedly disapprove'
+ );
const saveReviewPromise = interceptSaveReview();
@@ -1616,7 +1622,7 @@
assert.isFalse(element.attentionExpanded);
- element.patchsetLevelDraftMessage = 'a test comment';
+ element.patchsetLevelComment = element.createDraft('a test comment');
await element.updateComplete;
modifyButton.click();
@@ -2066,11 +2072,11 @@
const expectedError = new Error('test');
setup(() => {
- element.patchsetLevelDraftMessage = expectedDraft;
+ element.patchsetLevelComment = element.createDraft(expectedDraft);
});
function assertDialogOpenAndEnabled() {
- assert.strictEqual(expectedDraft, element.patchsetLevelDraftMessage);
+ assert.strictEqual(expectedDraft, element.patchsetLevelComment.message);
assert.isFalse(element.disabled);
}
@@ -2116,7 +2122,7 @@
// Mock canBeStarted
element.canBeStarted = true;
element.draftCommentThreads = [];
- element.patchsetLevelDraftMessage = '';
+ element.patchsetLevelComment = element.createDraft('');
element.reviewersMutated = false;
element.labelsChanged = false;
element.includeComments = false;
@@ -2130,7 +2136,7 @@
// Mock everything false
element.canBeStarted = false;
element.draftCommentThreads = [];
- element.patchsetLevelDraftMessage = '';
+ element.patchsetLevelComment = element.createDraft('');
element.reviewersMutated = false;
element.labelsChanged = false;
element.includeComments = false;
@@ -2144,7 +2150,7 @@
// Mock nonempty comment draft array; with sending comments.
element.canBeStarted = false;
element.draftCommentThreads = [{...createCommentThread([createComment()])}];
- element.patchsetLevelDraftMessage = '';
+ element.patchsetLevelComment = element.createDraft('');
element.reviewersMutated = false;
element.labelsChanged = false;
element.includeComments = true;
@@ -2158,7 +2164,7 @@
// Mock nonempty comment draft array; without sending comments.
element.canBeStarted = false;
element.draftCommentThreads = [{...createCommentThread([createComment()])}];
- element.patchsetLevelDraftMessage = '';
+ element.patchsetLevelComment = element.createDraft('');
element.reviewersMutated = false;
element.labelsChanged = false;
element.includeComments = false;
@@ -2173,7 +2179,7 @@
// Mock nonempty change message.
element.canBeStarted = false;
element.draftCommentThreads = [{...createCommentThread([createComment()])}];
- element.patchsetLevelDraftMessage = 'test';
+ element.patchsetLevelComment = element.createDraft('test');
element.reviewersMutated = false;
element.labelsChanged = false;
element.includeComments = false;
@@ -2188,7 +2194,7 @@
// Mock reviewers mutated.
element.canBeStarted = false;
element.draftCommentThreads = [{...createCommentThread([createComment()])}];
- element.patchsetLevelDraftMessage = '';
+ element.patchsetLevelComment = element.createDraft('');
element.reviewersMutated = true;
element.labelsChanged = false;
element.includeComments = false;
@@ -2203,7 +2209,7 @@
// Mock labels changed.
element.canBeStarted = false;
element.draftCommentThreads = [{...createCommentThread([createComment()])}];
- element.patchsetLevelDraftMessage = '';
+ element.patchsetLevelComment = element.createDraft('');
element.reviewersMutated = false;
element.labelsChanged = true;
element.includeComments = false;
@@ -2218,7 +2224,7 @@
// Whole dialog is disabled.
element.canBeStarted = false;
element.draftCommentThreads = [{...createCommentThread([createComment()])}];
- element.patchsetLevelDraftMessage = '';
+ element.patchsetLevelComment = element.createDraft('');
element.reviewersMutated = false;
element.labelsChanged = true;
element.includeComments = false;
@@ -2236,7 +2242,7 @@
).all = [account];
element.canBeStarted = false;
element.draftCommentThreads = [{...createCommentThread([createComment()])}];
- element.patchsetLevelDraftMessage = '';
+ element.patchsetLevelComment = element.createDraft('');
element.reviewersMutated = false;
element.labelsChanged = false;
element.includeComments = false;
@@ -2305,13 +2311,13 @@
queryAndAssert<GrComment>(element, '#patchsetLevelComment').messageText =
'hello';
- await waitUntil(() => element.patchsetLevelDraftMessage === 'hello');
+ await waitUntil(() => element.patchsetLevelComment.message === 'hello');
assert.isFalse(element.computeSendButtonDisabled());
queryAndAssert<GrComment>(element, '#patchsetLevelComment').messageText =
'';
- await waitUntil(() => element.patchsetLevelDraftMessage === '');
+ await waitUntil(() => element.patchsetLevelComment.message === '');
assert.isTrue(element.computeSendButtonDisabled());
});
@@ -2327,7 +2333,7 @@
patchsetLevelComment.messageText = 'hello world';
await waitUntil(
- () => element.patchsetLevelDraftMessage === 'hello world'
+ () => element.patchsetLevelComment.message === 'hello world'
);
const saveReviewPromise = interceptSaveReview();
@@ -2367,7 +2373,7 @@
patchsetLevelComment.messageText = 'hello world';
await waitUntil(
- () => element.patchsetLevelDraftMessage === 'hello world'
+ () => element.patchsetLevelComment.message === 'hello world'
);
assert.deepEqual(autoSaveStub.callCount, 0);
@@ -2390,7 +2396,7 @@
await waitUntil(() => element.draftCommentThreads.length === 1);
// patchset level draft as a reply is not loaded in patchsetLevel comment
- assert.equal(element.patchsetLevelDraftMessage, '');
+ assert.equal(element.patchsetLevelComment.message, '');
assert.deepEqual(element.draftCommentThreads[0].comments[0], draft);
});
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
index eb9dc41..78a1509 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
@@ -452,7 +452,7 @@
unresolved: this.unresolved,
saving: this.saving,
};
- return this.patched.html`
+ return html`
${this.renderFilePath()}
<div id="container">
<h3 class="assistive-tech-only">${this.computeAriaHeading()}</h3>
@@ -504,7 +504,7 @@
// because we ran into spurious issues with <gr-comment> being destroyed
// and re-created when an unsaved draft transitions to 'saved' state.
const draftComment = this.renderComment(this.getDraftOrUnsaved());
- return this.patched.html`${publishedComments}${draftComment}`;
+ return html`${publishedComments}${draftComment}`;
}
private renderComment(comment?: Comment) {
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
index ad2daf5..387554a 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
@@ -968,8 +968,16 @@
override willUpdate(changed: PropertyValues) {
this.firstWillUpdate();
+ if (changed.has('editing') || changed.has('comment')) {
+ this.reflectCommentToInternalFields();
+ }
if (changed.has('editing')) {
- this.onEditingChanged();
+ // Parent components such as the reply dialog might be interested in whether
+ // come of their child components are in editing mode.
+ fire(this, 'comment-editing-changed', {
+ editing: this.editing,
+ path: this.comment?.path ?? '',
+ });
}
if (changed.has('unresolved')) {
// The <gr-comment-thread> component wants to change its color based on
@@ -1047,22 +1055,14 @@
throw new Error('unable to create preview fix event');
}
- private onEditingChanged() {
- if (this.editing) {
- this.collapsed = false;
- this.messageText = this.comment?.message ?? '';
- this.unresolved = this.comment?.unresolved ?? true;
- this.originalMessage = this.messageText;
- this.originalUnresolved = this.unresolved;
- setTimeout(() => this.textarea?.putCursorAtEnd(), 1);
- }
-
- // Parent components such as the reply dialog might be interested in whether
- // come of their child components are in editing mode.
- fire(this, 'comment-editing-changed', {
- editing: this.editing,
- path: this.comment?.path ?? '',
- });
+ private reflectCommentToInternalFields() {
+ if (!this.editing) return;
+ this.collapsed = false;
+ this.messageText = this.comment?.message ?? '';
+ this.unresolved = this.comment?.unresolved ?? true;
+ this.originalMessage = this.messageText;
+ this.originalUnresolved = this.unresolved;
+ setTimeout(() => this.textarea?.putCursorAtEnd(), 1);
}
// private, but visible for testing
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts
index 8b651f1..d6a4d94 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts
+++ b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts
@@ -370,7 +370,10 @@
content = this.content || '';
}
- this.newContent = content;
+ // TODO(wyatta) switch linkify sequence, see issue 5526.
+ this.newContent = this.removeZeroWidthSpace
+ ? content.replace(/^R=\u200B/gm, 'R=')
+ : content;
}
computeSaveDisabled(): boolean {
diff --git a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts
index da90b17..6ab0ec4 100644
--- a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts
+++ b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts
@@ -154,7 +154,17 @@
// for this.
// 4. Rewrite plain text ("text") to apply linking and other config-based
// rewrites. Text within code blocks is not passed here.
+ // 5. Open links in a new tab by rendering with target="_blank" attribute.
function customRenderer(renderer: {[type: string]: Function}) {
+ renderer['link'] = (href: string, title: string, text: string) =>
+ /* HTML */
+ `<a
+ href="${href}"
+ target="_blank"
+ ${title ? `title="${title}"` : ''}
+ rel="noopener"
+ >${text}</a
+ >`;
renderer['image'] = (href: string, _title: string, text: string) =>
`![${text}](${href})`;
renderer['codespan'] = (text: string) =>
diff --git a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.ts b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.ts
index f3c9d9c..6391347 100644
--- a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.ts
@@ -51,7 +51,15 @@
match: 'HTMLRewriteMe',
html: '<div>HTMLRewritten</div>',
},
+ complexLinkRewrite: {
+ match: '(^|\\s)A Link (\\d+)($|\\s)',
+ link: '/page?id=$2',
+ text: 'Link $2',
+ prefix: '$1A ',
+ suffix: '$3',
+ },
});
+ self.CANONICAL_PATH = 'http://localhost';
element = (
await fixture(
wrapInProvider(
@@ -72,6 +80,7 @@
test('renders text with links and rewrites', async () => {
element.content = `text with plain link: google.com
\ntext with config link: LinkRewriteMe
+ \ntext with complex link: A Link 12
\ntext with config html: HTMLRewriteMe`;
await element.updateComplete;
@@ -91,6 +100,14 @@
>
LinkRewriteMe
</a>
+ text with complex link: A
+ <a
+ href="http://localhost/page?id=12"
+ rel="noopener"
+ target="_blank"
+ >
+ Link 12
+ </a>
text with config html:
<div>HTMLRewritten</div>
</pre>
@@ -129,6 +146,8 @@
element.content = `text
\ntext with plain link: google.com
\ntext with config link: LinkRewriteMe
+ \ntext without a link: NotA Link 15 cats
+ \ntext with complex link: A Link 12
\ntext with config html: HTMLRewriteMe`;
await element.updateComplete;
@@ -154,6 +173,17 @@
LinkRewriteMe
</a>
</p>
+ <p>text without a link: NotA Link 15 cats</p>
+ <p>
+ text with complex link: A
+ <a
+ href="http://localhost/page?id=12"
+ rel="noopener"
+ target="_blank"
+ >
+ Link 12
+ </a>
+ </p>
<p>text with config html:</p>
<div>HTMLRewritten</div>
<p></p>
@@ -302,7 +332,13 @@
<div slot="markdown-html">
<p>
@
- <a href="mailto:someone@google.com"> someone@google.com </a>
+ <a
+ href="mailto:someone@google.com"
+ rel="noopener"
+ target="_blank"
+ >
+ someone@google.com
+ </a>
</p>
</div>
</marked-element>
@@ -353,7 +389,13 @@
<div slot="markdown-html">
<p>
<code>@</code>
- <a href="mailto:someone@google.com"> someone@google.com </a>
+ <a
+ href="mailto:someone@google.com"
+ rel="noopener"
+ target="_blank"
+ >
+ someone@google.com
+ </a>
</p>
</div>
</marked-element>
@@ -371,7 +413,9 @@
<marked-element>
<div slot="markdown-html">
<p>
- <a href="https://www.google.com">myLink</a>
+ <a href="https://www.google.com" rel="noopener" target="_blank"
+ >myLink</a
+ >
</p>
</div>
</marked-element>
@@ -452,7 +496,9 @@
<p>block quote ${escapedDiv}</p>
</blockquote>
<p>
- <a href="http://google.com">inline link ${escapedDiv}</a>
+ <a href="http://google.com" rel="noopener" target="_blank"
+ >inline link ${escapedDiv}</a
+ >
</p>
</div>
</marked-element>
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.ts b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.ts
new file mode 100644
index 0000000..16a60e7
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.ts
@@ -0,0 +1,178 @@
+/**
+ * @license
+ * Copyright 2015 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../styles/shared-styles';
+import {GrLinkTextParser, LinkTextParserConfig} from './link-text-parser';
+import {LitElement, css, html, PropertyValues} from 'lit';
+import {customElement, property} from 'lit/decorators.js';
+import {assertIsDefined} from '../../../utils/common-util';
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-linked-text': GrLinkedText;
+ }
+}
+
+@customElement('gr-linked-text')
+export class GrLinkedText extends LitElement {
+ private outputElement?: HTMLSpanElement;
+
+ @property({type: Boolean, attribute: 'remove-zero-width-space'})
+ removeZeroWidthSpace?: boolean;
+
+ @property({type: String})
+ content = '';
+
+ @property({type: Boolean, attribute: true})
+ pre = false;
+
+ @property({type: Boolean, attribute: true})
+ disabled = false;
+
+ @property({type: Boolean, attribute: true})
+ inline = false;
+
+ @property({type: Object})
+ config?: LinkTextParserConfig;
+
+ static override get styles() {
+ return css`
+ :host {
+ display: block;
+ }
+ :host([inline]) {
+ display: inline;
+ }
+ :host([pre]) ::slotted(span) {
+ white-space: var(--linked-text-white-space, pre-wrap);
+ word-wrap: var(--linked-text-word-wrap, break-word);
+ }
+ `;
+ }
+
+ override render() {
+ return html`<slot name="insert"></slot>`;
+ }
+
+ // NOTE: LinkTextParser dynamically creates HTML fragments based on backend
+ // configuration commentLinks. These commentLinks can contain arbitrary HTML
+ // fragments. This means that arbitrary HTML needs to be injected into the
+ // DOM-tree, where this HTML is is controlled on the server-side in the
+ // server-configuration rather than by arbitrary users.
+ // To enable this injection of 'unsafe' HTML, LinkTextParser generates
+ // HTML fragments. Lit does not support inserting html fragments directly
+ // into its DOM-tree as it controls the DOM-tree that it generates.
+ // Therefore, to get around this we create a single element that we slot into
+ // the Lit-owned DOM. This element will not be part of this LitElement as
+ // it's slotted in and thus can be modified on the fly by handleParseResult.
+ override firstUpdated(_changedProperties: PropertyValues): void {
+ this.outputElement = document.createElement('span');
+ this.outputElement.id = 'output';
+ this.outputElement.slot = 'insert';
+ this.append(this.outputElement);
+ }
+
+ override updated(changedProperties: PropertyValues): void {
+ if (changedProperties.has('content') || changedProperties.has('config')) {
+ this._contentOrConfigChanged();
+ } else if (changedProperties.has('disabled')) {
+ this.styleLinks();
+ }
+ }
+
+ /**
+ * Because either the source text or the linkification config has changed,
+ * the content should be re-parsed.
+ * Private but used in tests.
+ *
+ * @param content The raw, un-linkified source string to parse.
+ * @param config The server config specifying commentLink patterns
+ */
+ _contentOrConfigChanged() {
+ if (!this.config) {
+ assertIsDefined(this.outputElement);
+ this.outputElement.textContent = this.content;
+ return;
+ }
+
+ assertIsDefined(this.outputElement);
+ this.outputElement.textContent = '';
+ const parser = new GrLinkTextParser(
+ this.config,
+ (text: string | null, href: string | null, fragment?: DocumentFragment) =>
+ this.handleParseResult(text, href, fragment),
+ this.removeZeroWidthSpace
+ );
+ parser.parse(this.content);
+
+ // Ensure that external links originating from HTML commentlink configs
+ // open in a new tab. @see Issue 5567
+ // Ensure links to the same host originating from commentlink configs
+ // open in the same tab. When target is not set - default is _self
+ // @see Issue 4616
+ this.outputElement.querySelectorAll('a').forEach(anchor => {
+ if (anchor.hostname === window.location.hostname) {
+ anchor.removeAttribute('target');
+ } else {
+ anchor.setAttribute('target', '_blank');
+ }
+ anchor.setAttribute('rel', 'noopener');
+ });
+
+ this.styleLinks();
+ }
+
+ /**
+ * Styles the links based on whether gr-linked-text is disabled or not
+ */
+ private styleLinks() {
+ assertIsDefined(this.outputElement);
+ this.outputElement.querySelectorAll('a').forEach(anchor => {
+ anchor.setAttribute('style', this.computeLinkStyle());
+ });
+ }
+
+ private computeLinkStyle() {
+ if (this.disabled) {
+ return `
+ color: inherit;
+ text-decoration: none;
+ pointer-events: none;
+ `;
+ } else {
+ return 'color: var(--link-color)';
+ }
+ }
+
+ /**
+ * This method is called when the GrLikTextParser emits a partial result
+ * (used as the "callback" parameter). It will be called in either of two
+ * ways:
+ * - To create a link: when called with `text` and `href` arguments, a link
+ * element should be created and attached to the resulting DOM.
+ * - To attach an arbitrary fragment: when called with only the `fragment`
+ * argument, the fragment should be attached to the resulting DOM as is.
+ */
+ private handleParseResult(
+ text: string | null,
+ href: string | null,
+ fragment?: DocumentFragment
+ ) {
+ assertIsDefined(this.outputElement);
+ const output = this.outputElement;
+ if (href) {
+ const a = document.createElement('a');
+ a.setAttribute('href', href);
+ // GrLinkTextParser either pass text and href together or
+ // only DocumentFragment - see LinkTextParserCallback
+ a.textContent = text!;
+ a.target = '_blank';
+ a.setAttribute('rel', 'noopener');
+ output.appendChild(a);
+ } else if (fragment) {
+ output.appendChild(fragment);
+ }
+ }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.ts b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.ts
new file mode 100644
index 0000000..00e0313
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.ts
@@ -0,0 +1,471 @@
+/**
+ * @license
+ * Copyright 2015 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-linked-text';
+import {fixture, html, assert} from '@open-wc/testing';
+import {GrLinkedText} from './gr-linked-text';
+import {queryAndAssert} from '../../../test/test-utils';
+
+suite('gr-linked-text tests', () => {
+ let element: GrLinkedText;
+
+ let originalCanonicalPath: string | undefined;
+
+ setup(async () => {
+ originalCanonicalPath = window.CANONICAL_PATH;
+ element = await fixture<GrLinkedText>(html`
+ <gr-linked-text>
+ <div id="output"></div>
+ </gr-linked-text>
+ `);
+
+ element.config = {
+ ph: {
+ match: '([Bb]ug|[Ii]ssue)\\s*#?(\\d+)',
+ link: 'https://bugs.chromium.org/p/gerrit/issues/detail?id=$2',
+ },
+ prefixsameinlinkandpattern: {
+ match: '([Hh][Tt][Tt][Pp]example)\\s*#?(\\d+)',
+ link: 'https://bugs.chromium.org/p/gerrit/issues/detail?id=$2',
+ },
+ changeid: {
+ match: '(I[0-9a-f]{8,40})',
+ link: '#/q/$1',
+ },
+ changeid2: {
+ match: 'Change-Id: +(I[0-9a-f]{8,40})',
+ link: '#/q/$1',
+ },
+ googlesearch: {
+ match: 'google:(.+)',
+ link: 'https://bing.com/search?q=$1', // html should supersede link.
+ html: '<a href="https://google.com/search?q=$1">$1</a>',
+ },
+ hashedhtml: {
+ match: 'hash:(.+)',
+ html: '<a href="#/awesomesauce">$1</a>',
+ },
+ baseurl: {
+ match: 'test (.+)',
+ html: '<a href="/r/awesomesauce">$1</a>',
+ },
+ anotatstartwithbaseurl: {
+ match: 'a test (.+)',
+ html: '[Lookup: <a href="/r/awesomesauce">$1</a>]',
+ },
+ disabledconfig: {
+ match: 'foo:(.+)',
+ link: 'https://google.com/search?q=$1',
+ enabled: false,
+ },
+ };
+ });
+
+ teardown(() => {
+ window.CANONICAL_PATH = originalCanonicalPath;
+ });
+
+ test('render', async () => {
+ element.content =
+ 'https://bugs.chromium.org/p/gerrit/issues/detail?id=3650';
+ await element.updateComplete;
+ assert.lightDom.equal(
+ element,
+ /* HTML */ `
+ <div id="output"></div>
+ <span id="output" slot="insert">
+ <a
+ href="https://bugs.chromium.org/p/gerrit/issues/detail?id=3650"
+ rel="noopener"
+ style="color: var(--link-color)"
+ target="_blank"
+ >
+ https://bugs.chromium.org/p/gerrit/issues/detail?id=3650
+ </a>
+ </span>
+ `
+ );
+ });
+
+ test('URL pattern was parsed and linked.', async () => {
+ // Regular inline link.
+ const url = 'https://bugs.chromium.org/p/gerrit/issues/detail?id=3650';
+ element.content = url;
+ await element.updateComplete;
+
+ const linkEl = queryAndAssert(element, 'span#output')
+ .childNodes[0] as HTMLAnchorElement;
+ assert.equal(linkEl.target, '_blank');
+ assert.equal(linkEl.rel, 'noopener');
+ assert.equal(linkEl.href, url);
+ assert.equal(linkEl.textContent, url);
+ });
+
+ test('Bug pattern was parsed and linked', async () => {
+ // "Issue/Bug" pattern.
+ element.content = 'Issue 3650';
+ await element.updateComplete;
+
+ let linkEl = queryAndAssert(element, 'span#output')
+ .childNodes[0] as HTMLAnchorElement;
+ const url = 'https://bugs.chromium.org/p/gerrit/issues/detail?id=3650';
+ assert.equal(linkEl.target, '_blank');
+ assert.equal(linkEl.href, url);
+ assert.equal(linkEl.textContent, 'Issue 3650');
+
+ element.content = 'Bug 3650';
+ await element.updateComplete;
+
+ linkEl = queryAndAssert(element, 'span#output')
+ .childNodes[0] as HTMLAnchorElement;
+ assert.equal(linkEl.target, '_blank');
+ assert.equal(linkEl.rel, 'noopener');
+ assert.equal(linkEl.href, url);
+ assert.equal(linkEl.textContent, 'Bug 3650');
+ });
+
+ test('Pattern with same prefix as link was correctly parsed', async () => {
+ // Pattern starts with the same prefix (`http`) as the url.
+ element.content = 'httpexample 3650';
+ await element.updateComplete;
+
+ assert.equal(queryAndAssert(element, 'span#output').childNodes.length, 1);
+ const linkEl = queryAndAssert(element, 'span#output')
+ .childNodes[0] as HTMLAnchorElement;
+ const url = 'https://bugs.chromium.org/p/gerrit/issues/detail?id=3650';
+ assert.equal(linkEl.target, '_blank');
+ assert.equal(linkEl.href, url);
+ assert.equal(linkEl.textContent, 'httpexample 3650');
+ });
+
+ test('Change-Id pattern was parsed and linked', async () => {
+ // "Change-Id:" pattern.
+ const changeID = 'I11d6a37f5e9b5df0486f6c922d8836dfa780e03e';
+ const prefix = 'Change-Id: ';
+ element.content = prefix + changeID;
+ await element.updateComplete;
+
+ const textNode = queryAndAssert(element, 'span#output').childNodes[0];
+ const linkEl = queryAndAssert(element, 'span#output')
+ .childNodes[1] as HTMLAnchorElement;
+ assert.equal(textNode.textContent, prefix);
+ const url = '/q/' + changeID;
+ assert.isFalse(linkEl.hasAttribute('target'));
+ // Since url is a path, the host is added automatically.
+ assert.isTrue(linkEl.href.endsWith(url));
+ assert.equal(linkEl.textContent, changeID);
+ });
+
+ test('Change-Id pattern was parsed and linked with base url', async () => {
+ window.CANONICAL_PATH = '/r';
+
+ // "Change-Id:" pattern.
+ const changeID = 'I11d6a37f5e9b5df0486f6c922d8836dfa780e03e';
+ const prefix = 'Change-Id: ';
+ element.content = prefix + changeID;
+ await element.updateComplete;
+
+ const textNode = queryAndAssert(element, 'span#output').childNodes[0];
+ const linkEl = queryAndAssert(element, 'span#output')
+ .childNodes[1] as HTMLAnchorElement;
+ assert.equal(textNode.textContent, prefix);
+ const url = '/r/q/' + changeID;
+ assert.isFalse(linkEl.hasAttribute('target'));
+ // Since url is a path, the host is added automatically.
+ assert.isTrue(linkEl.href.endsWith(url));
+ assert.equal(linkEl.textContent, changeID);
+ });
+
+ test('Multiple matches', async () => {
+ element.content = 'Issue 3650\nIssue 3450';
+ await element.updateComplete;
+
+ const linkEl1 = queryAndAssert(element, 'span#output')
+ .childNodes[0] as HTMLAnchorElement;
+ const linkEl2 = queryAndAssert(element, 'span#output')
+ .childNodes[2] as HTMLAnchorElement;
+
+ assert.equal(linkEl1.target, '_blank');
+ assert.equal(
+ linkEl1.href,
+ 'https://bugs.chromium.org/p/gerrit/issues/detail?id=3650'
+ );
+ assert.equal(linkEl1.textContent, 'Issue 3650');
+
+ assert.equal(linkEl2.target, '_blank');
+ assert.equal(
+ linkEl2.href,
+ 'https://bugs.chromium.org/p/gerrit/issues/detail?id=3450'
+ );
+ assert.equal(linkEl2.textContent, 'Issue 3450');
+ });
+
+ test('Change-Id pattern parsed before bug pattern', async () => {
+ // "Change-Id:" pattern.
+ const changeID = 'I11d6a37f5e9b5df0486f6c922d8836dfa780e03e';
+ const prefix = 'Change-Id: ';
+
+ // "Issue/Bug" pattern.
+ const bug = 'Issue 3650';
+
+ const changeUrl = '/q/' + changeID;
+ const bugUrl = 'https://bugs.chromium.org/p/gerrit/issues/detail?id=3650';
+
+ element.content = prefix + changeID + bug;
+ await element.updateComplete;
+
+ const textNode = queryAndAssert(element, 'span#output').childNodes[0];
+ const changeLinkEl = queryAndAssert(element, 'span#output')
+ .childNodes[1] as HTMLAnchorElement;
+ const bugLinkEl = queryAndAssert(element, 'span#output')
+ .childNodes[2] as HTMLAnchorElement;
+
+ assert.equal(textNode.textContent, prefix);
+
+ assert.isFalse(changeLinkEl.hasAttribute('target'));
+ assert.isTrue(changeLinkEl.href.endsWith(changeUrl));
+ assert.equal(changeLinkEl.textContent, changeID);
+
+ assert.equal(bugLinkEl.target, '_blank');
+ assert.equal(bugLinkEl.href, bugUrl);
+ assert.equal(bugLinkEl.textContent, 'Issue 3650');
+ });
+
+ test('html field in link config', async () => {
+ element.content = 'google:do a barrel roll';
+ await element.updateComplete;
+
+ const linkEl = queryAndAssert(element, 'span#output')
+ .childNodes[0] as HTMLAnchorElement;
+ assert.equal(
+ linkEl.getAttribute('href'),
+ 'https://google.com/search?q=do a barrel roll'
+ );
+ assert.equal(linkEl.textContent, 'do a barrel roll');
+ });
+
+ test('removing hash from links', async () => {
+ element.content = 'hash:foo';
+ await element.updateComplete;
+
+ const linkEl = queryAndAssert(element, 'span#output')
+ .childNodes[0] as HTMLAnchorElement;
+ assert.isTrue(linkEl.href.endsWith('/awesomesauce'));
+ assert.equal(linkEl.textContent, 'foo');
+ });
+
+ test('html with base url', async () => {
+ window.CANONICAL_PATH = '/r';
+
+ element.content = 'test foo';
+ await element.updateComplete;
+
+ const linkEl = queryAndAssert(element, 'span#output')
+ .childNodes[0] as HTMLAnchorElement;
+ assert.isTrue(linkEl.href.endsWith('/r/awesomesauce'));
+ assert.equal(linkEl.textContent, 'foo');
+ });
+
+ test('a is not at start', async () => {
+ window.CANONICAL_PATH = '/r';
+
+ element.content = 'a test foo';
+ await element.updateComplete;
+
+ const linkEl = queryAndAssert(element, 'span#output')
+ .childNodes[1] as HTMLAnchorElement;
+ assert.isTrue(linkEl.href.endsWith('/r/awesomesauce'));
+ assert.equal(linkEl.textContent, 'foo');
+ });
+
+ test('hash html with base url', async () => {
+ window.CANONICAL_PATH = '/r';
+
+ element.content = 'hash:foo';
+ await element.updateComplete;
+
+ const linkEl = queryAndAssert(element, 'span#output')
+ .childNodes[0] as HTMLAnchorElement;
+ assert.isTrue(linkEl.href.endsWith('/r/awesomesauce'));
+ assert.equal(linkEl.textContent, 'foo');
+ });
+
+ test('disabled config', async () => {
+ element.content = 'foo:baz';
+ await element.updateComplete;
+
+ assert.equal(queryAndAssert(element, 'span#output').innerHTML, 'foo:baz');
+ });
+
+ test('R=email labels link correctly', async () => {
+ element.removeZeroWidthSpace = true;
+ element.content = 'R=\u200Btest@google.com';
+ await element.updateComplete;
+
+ assert.equal(
+ queryAndAssert(element, 'span#output').textContent,
+ 'R=test@google.com'
+ );
+ assert.equal(
+ queryAndAssert(element, 'span#output').innerHTML.match(/(R=<a)/g)!.length,
+ 1
+ );
+ });
+
+ test('CC=email labels link correctly', async () => {
+ element.removeZeroWidthSpace = true;
+ element.content = 'CC=\u200Btest@google.com';
+ await element.updateComplete;
+
+ assert.equal(
+ queryAndAssert(element, 'span#output').textContent,
+ 'CC=test@google.com'
+ );
+ assert.equal(
+ queryAndAssert(element, 'span#output').innerHTML.match(/(CC=<a)/g)!
+ .length,
+ 1
+ );
+ });
+
+ test('only {http,https,mailto} protocols are linkified', async () => {
+ element.content = 'xx mailto:test@google.com yy';
+ await element.updateComplete;
+
+ let links = queryAndAssert(element, 'span#output').querySelectorAll('a');
+ assert.equal(links.length, 1);
+ assert.equal(links[0].getAttribute('href'), 'mailto:test@google.com');
+ assert.equal(links[0].innerHTML, 'mailto:test@google.com');
+
+ element.content = 'xx http://google.com yy';
+ await element.updateComplete;
+
+ links = queryAndAssert(element, 'span#output').querySelectorAll('a');
+ assert.equal(links.length, 1);
+ assert.equal(links[0].getAttribute('href'), 'http://google.com');
+ assert.equal(links[0].innerHTML, 'http://google.com');
+
+ element.content = 'xx https://google.com yy';
+ await element.updateComplete;
+
+ links = queryAndAssert(element, 'span#output').querySelectorAll('a');
+ assert.equal(links.length, 1);
+ assert.equal(links[0].getAttribute('href'), 'https://google.com');
+ assert.equal(links[0].innerHTML, 'https://google.com');
+
+ element.content = 'xx ssh://google.com yy';
+ await element.updateComplete;
+
+ links = queryAndAssert(element, 'span#output').querySelectorAll('a');
+ assert.equal(links.length, 0);
+
+ element.content = 'xx ftp://google.com yy';
+ await element.updateComplete;
+
+ links = queryAndAssert(element, 'span#output').querySelectorAll('a');
+ assert.equal(links.length, 0);
+ });
+
+ test('links without leading whitespace are linkified', async () => {
+ element.content = 'xx abcmailto:test@google.com yy';
+ await element.updateComplete;
+
+ assert.equal(
+ queryAndAssert(element, 'span#output').innerHTML.substr(0, 6),
+ 'xx abc'
+ );
+ let links = queryAndAssert(element, 'span#output').querySelectorAll('a');
+ assert.equal(links.length, 1);
+ assert.equal(links[0].getAttribute('href'), 'mailto:test@google.com');
+ assert.equal(links[0].innerHTML, 'mailto:test@google.com');
+
+ element.content = 'xx defhttp://google.com yy';
+ await element.updateComplete;
+
+ assert.equal(
+ queryAndAssert(element, 'span#output').innerHTML.substr(0, 6),
+ 'xx def'
+ );
+ links = queryAndAssert(element, 'span#output').querySelectorAll('a');
+ assert.equal(links.length, 1);
+ assert.equal(links[0].getAttribute('href'), 'http://google.com');
+ assert.equal(links[0].innerHTML, 'http://google.com');
+
+ element.content = 'xx qwehttps://google.com yy';
+ await element.updateComplete;
+
+ assert.equal(
+ queryAndAssert(element, 'span#output').innerHTML.substr(0, 6),
+ 'xx qwe'
+ );
+ links = queryAndAssert(element, 'span#output').querySelectorAll('a');
+ assert.equal(links.length, 1);
+ assert.equal(links[0].getAttribute('href'), 'https://google.com');
+ assert.equal(links[0].innerHTML, 'https://google.com');
+
+ // Non-latin character
+ element.content = 'xx абвhttps://google.com yy';
+ await element.updateComplete;
+
+ assert.equal(
+ queryAndAssert(element, 'span#output').innerHTML.substr(0, 6),
+ 'xx абв'
+ );
+ links = queryAndAssert(element, 'span#output').querySelectorAll('a');
+ assert.equal(links.length, 1);
+ assert.equal(links[0].getAttribute('href'), 'https://google.com');
+ assert.equal(links[0].innerHTML, 'https://google.com');
+
+ element.content = 'xx ssh://google.com yy';
+ await element.updateComplete;
+
+ links = queryAndAssert(element, 'span#output').querySelectorAll('a');
+ assert.equal(links.length, 0);
+
+ element.content = 'xx ftp://google.com yy';
+ await element.updateComplete;
+
+ links = queryAndAssert(element, 'span#output').querySelectorAll('a');
+ assert.equal(links.length, 0);
+ });
+
+ test('overlapping links', async () => {
+ element.config = {
+ b1: {
+ match: '(B:\\s*)(\\d+)',
+ html: '$1<a href="ftp://foo/$2">$2</a>',
+ },
+ b2: {
+ match: '(B:\\s*\\d+\\s*,\\s*)(\\d+)',
+ html: '$1<a href="ftp://foo/$2">$2</a>',
+ },
+ };
+ element.content = '- B: 123, 45';
+ await element.updateComplete;
+
+ const links = element.querySelectorAll('a');
+
+ assert.equal(links.length, 2);
+ assert.equal(
+ queryAndAssert<HTMLSpanElement>(element, 'span').textContent,
+ '- B: 123, 45'
+ );
+
+ assert.equal(links[0].href, 'ftp://foo/123');
+ assert.equal(links[0].textContent, '123');
+
+ assert.equal(links[1].href, 'ftp://foo/45');
+ assert.equal(links[1].textContent, '45');
+ });
+
+ test('_contentOrConfigChanged called with config', async () => {
+ const contentConfigStub = sinon.stub(element, '_contentOrConfigChanged');
+ element.content = 'some text';
+ await element.updateComplete;
+
+ assert.isTrue(contentConfigStub.called);
+ });
+});
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-text/link-text-parser.ts b/polygerrit-ui/app/elements/shared/gr-linked-text/link-text-parser.ts
new file mode 100644
index 0000000..73cf58b
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-linked-text/link-text-parser.ts
@@ -0,0 +1,415 @@
+/**
+ * @license
+ * Copyright 2015 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import 'ba-linkify/ba-linkify';
+import {getBaseUrl} from '../../../utils/url-util';
+import {CommentLinkInfo} from '../../../types/common';
+
+/**
+ * Pattern describing URLs with supported protocols.
+ */
+const URL_PROTOCOL_PATTERN = /^(.*)(https?:\/\/|mailto:)/;
+
+export type LinkTextParserCallback = ((text: string, href: string) => void) &
+ ((text: null, href: null, fragment: DocumentFragment) => void);
+
+export interface CommentLinkItem {
+ position: number;
+ length: number;
+ html: HTMLAnchorElement | DocumentFragment;
+}
+
+export type LinkTextParserConfig = {[name: string]: CommentLinkInfo};
+
+export class GrLinkTextParser {
+ private readonly baseUrl = getBaseUrl();
+
+ /**
+ * Construct a parser for linkifying text. Will linkify plain URLs that appear
+ * in the text as well as custom links if any are specified in the linkConfig
+ * parameter.
+ *
+ * @param linkConfig Comment links as specified by the commentlinks field on a
+ * project config.
+ * @param callback The callback to be fired when an intermediate parse result
+ * is emitted. The callback is passed text and href strings if a link is to
+ * be created, or a document fragment otherwise.
+ * @param removeZeroWidthSpace If true, zero-width spaces will be removed from
+ * R=<email> and CC=<email> expressions.
+ */
+ constructor(
+ private readonly linkConfig: LinkTextParserConfig,
+ private readonly callback: LinkTextParserCallback,
+ private readonly removeZeroWidthSpace?: boolean
+ ) {
+ Object.preventExtensions(this);
+ }
+
+ /**
+ * Emit a callback to create a link element.
+ *
+ * @param text The text of the link.
+ * @param href The URL to use as the href of the link.
+ */
+ addText(text: string, href: string) {
+ if (!text) {
+ return;
+ }
+ this.callback(text, href);
+ }
+
+ /**
+ * Given the source text and a list of CommentLinkItem objects that were
+ * generated by the commentlinks config, emit parsing callbacks.
+ *
+ * @param text The chuml of source text over which the outputArray items range.
+ * @param outputArray The list of items to add resulting from commentlink
+ * matches.
+ */
+ processLinks(text: string, outputArray: CommentLinkItem[]) {
+ this.sortArrayReverse(outputArray);
+ const fragment = document.createDocumentFragment();
+ let cursor = text.length;
+
+ // Start inserting linkified URLs from the end of the String. That way, the
+ // string positions of the items don't change as we iterate through.
+ outputArray.forEach(item => {
+ // Add any text between the current linkified item and the item added
+ // before if it exists.
+ if (item.position + item.length !== cursor) {
+ fragment.insertBefore(
+ document.createTextNode(
+ text.slice(item.position + item.length, cursor)
+ ),
+ fragment.firstChild
+ );
+ }
+ fragment.insertBefore(item.html, fragment.firstChild);
+ cursor = item.position;
+ });
+
+ // Add the beginning portion at the end.
+ if (cursor !== 0) {
+ fragment.insertBefore(
+ document.createTextNode(text.slice(0, cursor)),
+ fragment.firstChild
+ );
+ }
+
+ this.callback(null, null, fragment);
+ }
+
+ /**
+ * Sort the given array of CommentLinkItems such that the positions are in
+ * reverse order.
+ */
+ sortArrayReverse(outputArray: CommentLinkItem[]) {
+ outputArray.sort((a, b) => b.position - a.position);
+ }
+
+ addItem(
+ text: string,
+ href: string,
+ html: null,
+ position: number,
+ length: number,
+ outputArray: CommentLinkItem[]
+ ): void;
+
+ addItem(
+ text: null,
+ href: null,
+ html: string,
+ position: number,
+ length: number,
+ outputArray: CommentLinkItem[]
+ ): void;
+
+ /**
+ * Create a CommentLinkItem and append it to the given output array. This
+ * method can be called in either of two ways:
+ * - With `text` and `href` parameters provided, and the `html` parameter
+ * passed as `null`. In this case, the new CommentLinkItem will be a link
+ * element with the given text and href value.
+ * - With the `html` paremeter provided, and the `text` and `href` parameters
+ * passed as `null`. In this case, the string of HTML will be parsed and the
+ * first resulting node will be used as the resulting content.
+ *
+ * @param text The text to use if creating a link.
+ * @param href The href to use as the URL if creating a link.
+ * @param html The html to parse and use as the result.
+ * @param position The position inside the source text where the item
+ * starts.
+ * @param length The number of characters in the source text
+ * represented by the item.
+ * @param outputArray The array to which the
+ * new item is to be appended.
+ */
+ addItem(
+ text: string | null,
+ href: string | null,
+ html: string | null,
+ position: number,
+ length: number,
+ outputArray: CommentLinkItem[]
+ ): void {
+ if (href) {
+ const a = document.createElement('a');
+ a.setAttribute('href', href);
+ a.textContent = text;
+ a.target = '_blank';
+ a.rel = 'noopener';
+ outputArray.push({
+ html: a,
+ position,
+ length,
+ });
+ } else if (html) {
+ // addItem has 2 overloads. If href is null, then html
+ // can't be null.
+ // TODO(TS): remove if(html) and keep else block without condition
+ const fragment = document.createDocumentFragment();
+ // Create temporary div to hold the nodes in.
+ const div = document.createElement('div');
+ div.innerHTML = html;
+ while (div.firstChild) {
+ fragment.appendChild(div.firstChild);
+ }
+ outputArray.push({
+ html: fragment,
+ position,
+ length,
+ });
+ }
+ }
+
+ /**
+ * Create a CommentLinkItem for a link and append it to the given output
+ * array.
+ *
+ * @param text The text for the link.
+ * @param href The href to use as the URL of the link.
+ * @param position The position inside the source text where the link
+ * starts.
+ * @param length The number of characters in the source text
+ * represented by the link.
+ * @param outputArray The array to which the
+ * new item is to be appended.
+ */
+ addLink(
+ text: string,
+ href: string,
+ position: number,
+ length: number,
+ outputArray: CommentLinkItem[]
+ ) {
+ // TODO(TS): remove !test condition
+ if (!text || this.hasOverlap(position, length, outputArray)) {
+ return;
+ }
+ if (
+ !!this.baseUrl &&
+ href.startsWith('/') &&
+ !href.startsWith(this.baseUrl)
+ ) {
+ href = this.baseUrl + href;
+ }
+ this.addItem(text, href, null, position, length, outputArray);
+ }
+
+ /**
+ * Create a CommentLinkItem specified by an HTMl string and append it to the
+ * given output array.
+ *
+ * @param html The html to parse and use as the result.
+ * @param position The position inside the source text where the item
+ * starts.
+ * @param length The number of characters in the source text
+ * represented by the item.
+ * @param outputArray The array to which the
+ * new item is to be appended.
+ */
+ addHTML(
+ html: string,
+ position: number,
+ length: number,
+ outputArray: CommentLinkItem[]
+ ) {
+ if (this.hasOverlap(position, length, outputArray)) {
+ return;
+ }
+ if (
+ !!this.baseUrl &&
+ html.match(/<a href="\//g) &&
+ !new RegExp(`<a href="${this.baseUrl}`, 'g').test(html)
+ ) {
+ html = html.replace(/<a href="\//g, `<a href="${this.baseUrl}/`);
+ }
+ this.addItem(null, null, html, position, length, outputArray);
+ }
+
+ /**
+ * Does the given range overlap with anything already in the item list.
+ */
+ hasOverlap(position: number, length: number, outputArray: CommentLinkItem[]) {
+ const endPosition = position + length;
+ for (let i = 0; i < outputArray.length; i++) {
+ const arrayItemStart = outputArray[i].position;
+ const arrayItemEnd = outputArray[i].position + outputArray[i].length;
+ if (
+ (position >= arrayItemStart && position < arrayItemEnd) ||
+ (endPosition > arrayItemStart && endPosition <= arrayItemEnd) ||
+ (position === arrayItemStart && position === arrayItemEnd)
+ ) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Parse the given source text and emit callbacks for the items that are
+ * parsed.
+ */
+ parse(text?: string | null) {
+ if (text) {
+ window.linkify(text, {
+ callback: (text: string, href?: string) => this.parseChunk(text, href),
+ });
+ }
+ }
+
+ /**
+ * Callback that is pased into the linkify function. ba-linkify will call this
+ * method in either of two ways:
+ * - With both a `text` and `href` parameter provided: this indicates that
+ * ba-linkify has found a plain URL and wants it linkified.
+ * - With only a `text` parameter provided: this represents the non-link
+ * content that lies between the links the library has found.
+ *
+ */
+ parseChunk(text: string, href?: string) {
+ // TODO(wyatta) switch linkify sequence, see issue 5526.
+ if (this.removeZeroWidthSpace) {
+ // Remove the zero-width space added in gr-change-view.
+ text = text.replace(/^(CC|R)=\u200B/gm, '$1=');
+ }
+
+ // If the href is provided then ba-linkify has recognized it as a URL. If
+ // the source text does not include a protocol, the protocol will be added
+ // by ba-linkify. Create the link if the href is provided and its protocol
+ // matches the expected pattern.
+ if (href) {
+ const result = URL_PROTOCOL_PATTERN.exec(href);
+ if (result) {
+ const prefixText = result[1];
+ if (prefixText.length > 0) {
+ // Fix for simple cases from
+ // https://bugs.chromium.org/p/gerrit/issues/detail?id=11697
+ // When leading whitespace is missed before link,
+ // linkify add this text before link as a schema name to href.
+ // We suppose, that prefixText just a single word
+ // before link and add this word as is, without processing
+ // any patterns in it.
+ this.parseLinks(prefixText, {});
+ text = text.substring(prefixText.length);
+ href = href.substring(prefixText.length);
+ }
+ this.addText(text, href);
+ return;
+ }
+ }
+ // For the sections of text that lie between the links found by
+ // ba-linkify, we search for the project-config-specified link patterns.
+ this.parseLinks(text, this.linkConfig);
+ }
+
+ /**
+ * Walk over the given source text to find matches for comemntlink patterns
+ * and emit parse result callbacks.
+ *
+ * @param text The raw source text.
+ * @param config A comment links specification object.
+ */
+ parseLinks(text: string, config: LinkTextParserConfig) {
+ // The outputArray is used to store all of the matches found for all
+ // patterns.
+ const outputArray: CommentLinkItem[] = [];
+ for (const [configName, linkInfo] of Object.entries(config)) {
+ // TODO(TS): it seems, the following line can be rewritten as:
+ // if(enabled === false || enabled === 0 || enabled === '')
+ // Should be double-checked before update
+ // eslint-disable-next-line eqeqeq
+ if (linkInfo.enabled != null && linkInfo.enabled == false) {
+ continue;
+ }
+ // PolyGerrit doesn't use hash-based navigation like the GWT UI.
+ // Account for this.
+ const html = linkInfo.html;
+ const link = linkInfo.link;
+ if (html) {
+ linkInfo.html = html.replace(/<a href="#\//g, '<a href="/');
+ } else if (link) {
+ if (link[0] === '#') {
+ linkInfo.link = link.substr(1);
+ }
+ }
+
+ const pattern = new RegExp(linkInfo.match, 'g');
+
+ let match;
+ let textToCheck = text;
+ let susbtrIndex = 0;
+
+ while ((match = pattern.exec(textToCheck))) {
+ textToCheck = textToCheck.substr(match.index + match[0].length);
+ let result = match[0].replace(
+ pattern,
+ // Either html or link has a value. Otherwise an exception is thrown
+ // in the code below.
+ (linkInfo.html || linkInfo.link)!
+ );
+
+ if (linkInfo.html) {
+ let i;
+ // Skip portion of replacement string that is equal to original to
+ // allow overlapping patterns.
+ for (i = 0; i < result.length; i++) {
+ if (result[i] !== match[0][i]) {
+ break;
+ }
+ }
+ result = result.slice(i);
+
+ this.addHTML(
+ result,
+ susbtrIndex + match.index + i,
+ match[0].length - i,
+ outputArray
+ );
+ } else if (linkInfo.link) {
+ this.addLink(
+ match[0],
+ result,
+ susbtrIndex + match.index,
+ match[0].length,
+ outputArray
+ );
+ } else {
+ throw Error(
+ 'linkconfig entry ' +
+ configName +
+ ' doesn’t contain a link or html attribute.'
+ );
+ }
+
+ // Update the substring location so we know where we are in relation to
+ // the initial full text string.
+ susbtrIndex = susbtrIndex + match.index + match[0].length;
+ }
+ }
+ this.processLinks(text, outputArray);
+ }
+}
diff --git a/polygerrit-ui/app/models/checks/checks-model.ts b/polygerrit-ui/app/models/checks/checks-model.ts
index bb46f27..8e058aa 100644
--- a/polygerrit-ui/app/models/checks/checks-model.ts
+++ b/polygerrit-ui/app/models/checks/checks-model.ts
@@ -574,6 +574,8 @@
summaryMessage: string | undefined,
patchset: ChecksPatchset
) {
+ // Protect against plugins not respecting required fields.
+ runs = runs.filter(run => !!run.checkName && !!run.status);
const attemptMap = createAttemptMap(runs);
for (const attemptInfo of attemptMap.values()) {
attemptInfo.attempts.sort(sortAttemptDetails);
diff --git a/polygerrit-ui/app/models/checks/checks-model_test.ts b/polygerrit-ui/app/models/checks/checks-model_test.ts
index f3fc665..88fbebc 100644
--- a/polygerrit-ui/app/models/checks/checks-model_test.ts
+++ b/polygerrit-ui/app/models/checks/checks-model_test.ts
@@ -147,6 +147,35 @@
assert.lengthOf(current.runs[0].results!, 1);
});
+ test('model.updateStateSetResults ignore empty name or status', () => {
+ model.updateStateSetProvider(PLUGIN_NAME, ChecksPatchset.LATEST);
+ model.updateStateSetResults(
+ PLUGIN_NAME,
+ [
+ {
+ checkName: 'test-check-name',
+ status: RunStatus.COMPLETED,
+ },
+ // Will be ignored, because the checkName is empty.
+ {
+ checkName: undefined as unknown as string,
+ status: RunStatus.COMPLETED,
+ },
+ // Will be ignored, because the status is empty.
+ {
+ checkName: 'test-check-name',
+ status: undefined as unknown as RunStatus,
+ },
+ ],
+ [],
+ [],
+ undefined,
+ ChecksPatchset.LATEST
+ );
+ // 2 out of 3 runs are ignored.
+ assert.lengthOf(current.runs, 1);
+ });
+
test('model.updateStateUpdateResult', () => {
model.updateStateSetProvider(PLUGIN_NAME, ChecksPatchset.LATEST);
model.updateStateSetResults(
diff --git a/polygerrit-ui/app/utils/account-util.ts b/polygerrit-ui/app/utils/account-util.ts
index a7d0587..207152c 100644
--- a/polygerrit-ui/app/utils/account-util.ts
+++ b/polygerrit-ui/app/utils/account-util.ts
@@ -29,7 +29,7 @@
const SUGGESTIONS_LIMIT = 15;
// https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address
export const MENTIONS_REGEX =
- /(?<=^|\s)@([a-zA-Z0-9.!#$%&'*+=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*)(?=\s+|$)/g;
+ /(?:^|\s)@([a-zA-Z0-9.!#$%&'*+=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*)(?=\s+|$)/g;
export function accountKey(account: AccountInfo): AccountId | EmailAddress {
if (account._account_id !== undefined) return account._account_id;
diff --git a/polygerrit-ui/app/utils/link-util.ts b/polygerrit-ui/app/utils/link-util.ts
index fd5965b..9079f4c 100644
--- a/polygerrit-ui/app/utils/link-util.ts
+++ b/polygerrit-ui/app/utils/link-util.ts
@@ -18,7 +18,7 @@
const parts: string[] = [];
window.linkify(baseWithZeroWidthSpace, {
callback: (text, href) => {
- const result = href ? createLinkTemplate(text, href) : text;
+ const result = href ? createLinkTemplate(href, text) : text;
const resultWithoutZeroWidthSpace = result.replace(/\u200B/g, '');
parts.push(resultWithoutZeroWidthSpace);
},
@@ -39,7 +39,12 @@
: rewrite.link!;
return {
match: new RegExp(rewrite.match, 'g'),
- replace: createLinkTemplate('$&', replacementHref),
+ replace: createLinkTemplate(
+ replacementHref,
+ rewrite.text ?? '$&',
+ rewrite.prefix,
+ rewrite.suffix
+ ),
};
});
return applyRewrites(base, rewrites);
@@ -71,6 +76,15 @@
);
}
-function createLinkTemplate(displayText: string, href: string) {
- return `<a href="${href}" rel="noopener" target="_blank">${displayText}</a>`;
+function createLinkTemplate(
+ href: string,
+ displayText: string,
+ prefix?: string,
+ suffix?: string
+) {
+ return `${
+ prefix ?? ''
+ }<a href="${href}" rel="noopener" target="_blank">${displayText}</a>${
+ suffix ?? ''
+ }`;
}
diff --git a/polygerrit-ui/app/utils/link-util_test.ts b/polygerrit-ui/app/utils/link-util_test.ts
index c491e35..a1ec2fa 100644
--- a/polygerrit-ui/app/utils/link-util_test.ts
+++ b/polygerrit-ui/app/utils/link-util_test.ts
@@ -30,11 +30,13 @@
'<h1>Change 12345 is the best change</h1> <div>FOO</div>'
);
});
+
test('applyLinkRewritesFromConfig', () => {
const linkedNumber = link('#12345', 'google.com/12345');
const linkedFoo = link('foo', 'foo.gov');
+ const linkedBar = link('Bar Page: 300', 'bar.com/page?id=300');
assert.equal(
- applyLinkRewritesFromConfig('#12345 foo', {
+ applyLinkRewritesFromConfig('#12345 foo crowbar:12 bar:300', {
'number-linker': {
match: '#(\\d+)',
link: 'google.com/$1',
@@ -43,8 +45,15 @@
match: 'foo',
link: 'foo.gov',
},
+ 'advanced-link': {
+ match: '(^|\\s)bar:(\\d+)($|\\s)',
+ link: 'bar.com/page?id=$2',
+ text: 'Bar Page: $2',
+ prefix: '$1',
+ suffix: '$3',
+ },
}),
- `${linkedNumber} ${linkedFoo}`
+ `${linkedNumber} ${linkedFoo} crowbar:12 ${linkedBar}`
);
});