Commit 87d1176c authored by Natalia Tepluhina's avatar Natalia Tepluhina

Merge branch '39048-generate-file-permalink-without-reloading-the-page' into 'master'

No-Reload URL Permalinks

See merge request gitlab-org/gitlab!22340
parents 53c9daeb f89e368a
import Mousetrap from 'mousetrap'; import Mousetrap from 'mousetrap';
import { getLocationHash, visitUrl } from '../../lib/utils/url_utility'; import {
getLocationHash,
updateHistory,
urlIsDifferent,
urlContainsSha,
getShaFromUrl,
} from '~/lib/utils/url_utility';
import { updateRefPortionOfTitle } from '~/repository/utils/title';
import Shortcuts from './shortcuts'; import Shortcuts from './shortcuts';
const defaults = { const defaults = {
skipResetBindings: false, skipResetBindings: false,
fileBlobPermalinkUrl: null, fileBlobPermalinkUrl: null,
fileBlobPermalinkUrlElement: null,
}; };
function eventHasModifierKeys(event) {
// We ignore alt because I don't think alt clicks normally do anything special?
return event.ctrlKey || event.metaKey || event.shiftKey;
}
export default class ShortcutsBlob extends Shortcuts { export default class ShortcutsBlob extends Shortcuts {
constructor(opts) { constructor(opts) {
const options = Object.assign({}, defaults, opts); const options = Object.assign({}, defaults, opts);
super(options.skipResetBindings); super(options.skipResetBindings);
this.options = options; this.options = options;
this.shortcircuitPermalinkButton();
Mousetrap.bind('y', this.moveToFilePermalink.bind(this)); Mousetrap.bind('y', this.moveToFilePermalink.bind(this));
} }
moveToFilePermalink() { moveToFilePermalink() {
if (this.options.fileBlobPermalinkUrl) { const permalink = this.options.fileBlobPermalinkUrl;
if (permalink) {
const hash = getLocationHash(); const hash = getLocationHash();
const hashUrlString = hash ? `#${hash}` : ''; const hashUrlString = hash ? `#${hash}` : '';
visitUrl(`${this.options.fileBlobPermalinkUrl}${hashUrlString}`);
if (urlIsDifferent(permalink)) {
updateHistory({
url: `${permalink}${hashUrlString}`,
title: document.title,
});
}
if (urlContainsSha({ url: permalink })) {
updateRefPortionOfTitle(getShaFromUrl({ url: permalink }));
}
}
}
shortcircuitPermalinkButton() {
const button = this.options.fileBlobPermalinkUrlElement;
const handleButton = e => {
if (!eventHasModifierKeys(e)) {
e.preventDefault();
this.moveToFilePermalink();
}
};
if (button) {
button.addEventListener('click', handleButton);
} }
} }
} }
const PATH_SEPARATOR = '/'; const PATH_SEPARATOR = '/';
const PATH_SEPARATOR_LEADING_REGEX = new RegExp(`^${PATH_SEPARATOR}+`); const PATH_SEPARATOR_LEADING_REGEX = new RegExp(`^${PATH_SEPARATOR}+`);
const PATH_SEPARATOR_ENDING_REGEX = new RegExp(`${PATH_SEPARATOR}+$`); const PATH_SEPARATOR_ENDING_REGEX = new RegExp(`${PATH_SEPARATOR}+$`);
const SHA_REGEX = /[\da-f]{40}/gi;
// Reset the cursor in a Regex so that multiple uses before a recompile don't fail
function resetRegExp(regex) {
regex.lastIndex = 0; /* eslint-disable-line no-param-reassign */
return regex;
}
// Returns a decoded url parameter value // Returns a decoded url parameter value
// - Treats '+' as '%20' // - Treats '+' as '%20'
...@@ -128,6 +136,20 @@ export function doesHashExistInUrl(hashName) { ...@@ -128,6 +136,20 @@ export function doesHashExistInUrl(hashName) {
return hash && hash.includes(hashName); return hash && hash.includes(hashName);
} }
export function urlContainsSha({ url = String(window.location) } = {}) {
return resetRegExp(SHA_REGEX).test(url);
}
export function getShaFromUrl({ url = String(window.location) } = {}) {
let sha = null;
if (urlContainsSha({ url })) {
[sha] = url.match(resetRegExp(SHA_REGEX));
}
return sha;
}
/** /**
* Apply the fragment to the given url by returning a new url string that includes * Apply the fragment to the given url by returning a new url string that includes
* the fragment. If the given url already contains a fragment, the original fragment * the fragment. If the given url already contains a fragment, the original fragment
...@@ -154,6 +176,16 @@ export function visitUrl(url, external = false) { ...@@ -154,6 +176,16 @@ export function visitUrl(url, external = false) {
} }
} }
export function updateHistory({ state = {}, title = '', url, replace = false, win = window } = {}) {
if (win.history) {
if (replace) {
win.history.replaceState(state, title, url);
} else {
win.history.pushState(state, title, url);
}
}
}
export function refreshCurrentPage() { export function refreshCurrentPage() {
visitUrl(window.location.href); visitUrl(window.location.href);
} }
...@@ -282,3 +314,7 @@ export const setUrlParams = (params, url = window.location.href, clearParams = f ...@@ -282,3 +314,7 @@ export const setUrlParams = (params, url = window.location.href, clearParams = f
}; };
export const escapeFileUrl = fileUrl => encodeURIComponent(fileUrl).replace(/%2F/g, '/'); export const escapeFileUrl = fileUrl => encodeURIComponent(fileUrl).replace(/%2F/g, '/');
export function urlIsDifferent(url, compare = String(window.location)) {
return url !== compare;
}
...@@ -25,6 +25,7 @@ export default () => { ...@@ -25,6 +25,7 @@ export default () => {
new ShortcutsBlob({ new ShortcutsBlob({
skipResetBindings: true, skipResetBindings: true,
fileBlobPermalinkUrl, fileBlobPermalinkUrl,
fileBlobPermalinkUrlElement,
}); });
new BlobForkSuggestion({ new BlobForkSuggestion({
......
const DEFAULT_TITLE = '· GitLab'; const DEFAULT_TITLE = '· GitLab';
// eslint-disable-next-line import/prefer-default-export
export const setTitle = (pathMatch, ref, project) => { export const setTitle = (pathMatch, ref, project) => {
if (!pathMatch) { if (!pathMatch) {
document.title = `${project} ${DEFAULT_TITLE}`; document.title = `${project} ${DEFAULT_TITLE}`;
...@@ -12,3 +12,15 @@ export const setTitle = (pathMatch, ref, project) => { ...@@ -12,3 +12,15 @@ export const setTitle = (pathMatch, ref, project) => {
/* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */ /* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */
document.title = `${isEmpty ? 'Files' : path} · ${ref} · ${project} ${DEFAULT_TITLE}`; document.title = `${isEmpty ? 'Files' : path} · ${ref} · ${project} ${DEFAULT_TITLE}`;
}; };
export function updateRefPortionOfTitle(sha, doc = document) {
const { title = '' } = doc;
const titleParts = title.split(' · ');
if (titleParts.length > 1) {
titleParts[1] = sha;
/* eslint-disable-next-line no-param-reassign */
doc.title = titleParts.join(' · ');
}
}
---
title: When switching to a file permalink, just change the URL instead of triggering
a useless page reload
merge_request: 22340
author:
type: added
import * as urlUtils from '~/lib/utils/url_utility'; import * as urlUtils from '~/lib/utils/url_utility';
const shas = {
valid: [
'ad9be38573f9ee4c4daec22673478c2dd1d81cd8',
'76e07a692f65a2f4fd72f107a3e83908bea9b7eb',
'9dd8f215b1e8605b1d59eaf9df1178081cda0aaf',
'f2e0be58c4091b033203bae1cc0302febd54117d',
],
invalid: [
'zd9be38573f9ee4c4daec22673478c2dd1d81cd8',
':6e07a692f65a2f4fd72f107a3e83908bea9b7eb',
'-dd8f215b1e8605b1d59eaf9df1178081cda0aaf',
' 2e0be58c4091b033203bae1cc0302febd54117d',
],
};
const setWindowLocation = value => { const setWindowLocation = value => {
Object.defineProperty(window, 'location', { Object.defineProperty(window, 'location', {
writable: true, writable: true,
...@@ -154,6 +169,44 @@ describe('URL utility', () => { ...@@ -154,6 +169,44 @@ describe('URL utility', () => {
}); });
}); });
describe('urlContainsSha', () => {
it('returns true when there is a valid 40-character SHA1 hash in the URL', () => {
shas.valid.forEach(sha => {
expect(
urlUtils.urlContainsSha({ url: `http://urlstuff/${sha}/moreurlstuff` }),
).toBeTruthy();
});
});
it('returns false when there is not a valid 40-character SHA1 hash in the URL', () => {
shas.invalid.forEach(str => {
expect(urlUtils.urlContainsSha({ url: `http://urlstuff/${str}/moreurlstuff` })).toBeFalsy();
});
});
});
describe('getShaFromUrl', () => {
let validUrls = [];
let invalidUrls = [];
beforeAll(() => {
validUrls = shas.valid.map(sha => `http://urlstuff/${sha}/moreurlstuff`);
invalidUrls = shas.invalid.map(str => `http://urlstuff/${str}/moreurlstuff`);
});
it('returns the valid 40-character SHA1 hash from the URL', () => {
validUrls.forEach((url, idx) => {
expect(urlUtils.getShaFromUrl({ url })).toBe(shas.valid[idx]);
});
});
it('returns null from a URL with no valid 40-character SHA1 hash', () => {
invalidUrls.forEach(url => {
expect(urlUtils.getShaFromUrl({ url })).toBeNull();
});
});
});
describe('setUrlFragment', () => { describe('setUrlFragment', () => {
it('should set fragment when url has no fragment', () => { it('should set fragment when url has no fragment', () => {
const url = urlUtils.setUrlFragment('/home/feature', 'usage'); const url = urlUtils.setUrlFragment('/home/feature', 'usage');
...@@ -174,6 +227,44 @@ describe('URL utility', () => { ...@@ -174,6 +227,44 @@ describe('URL utility', () => {
}); });
}); });
describe('updateHistory', () => {
const state = { key: 'prop' };
const title = 'TITLE';
const url = 'URL';
const win = {
history: {
pushState: jest.fn(),
replaceState: jest.fn(),
},
};
beforeEach(() => {
win.history.pushState.mockReset();
win.history.replaceState.mockReset();
});
it('should call replaceState if the replace option is true', () => {
urlUtils.updateHistory({ state, title, url, replace: true, win });
expect(win.history.replaceState).toHaveBeenCalledWith(state, title, url);
expect(win.history.pushState).not.toHaveBeenCalled();
});
it('should call pushState if the replace option is missing', () => {
urlUtils.updateHistory({ state, title, url, win });
expect(win.history.replaceState).not.toHaveBeenCalled();
expect(win.history.pushState).toHaveBeenCalledWith(state, title, url);
});
it('should call pushState if the replace option is false', () => {
urlUtils.updateHistory({ state, title, url, replace: false, win });
expect(win.history.replaceState).not.toHaveBeenCalled();
expect(win.history.pushState).toHaveBeenCalledWith(state, title, url);
});
});
describe('getBaseURL', () => { describe('getBaseURL', () => {
beforeEach(() => { beforeEach(() => {
setWindowLocation({ setWindowLocation({
...@@ -331,6 +422,22 @@ describe('URL utility', () => { ...@@ -331,6 +422,22 @@ describe('URL utility', () => {
}); });
}); });
describe('urlIsDifferent', () => {
beforeEach(() => {
setWindowLocation('current');
});
it('should compare against the window location if no compare value is provided', () => {
expect(urlUtils.urlIsDifferent('different')).toBeTruthy();
expect(urlUtils.urlIsDifferent('current')).toBeFalsy();
});
it('should use the provided compare value', () => {
expect(urlUtils.urlIsDifferent('different', 'current')).toBeTruthy();
expect(urlUtils.urlIsDifferent('current', 'current')).toBeFalsy();
});
});
describe('setUrlParams', () => { describe('setUrlParams', () => {
it('adds new params as query string', () => { it('adds new params as query string', () => {
const url = 'https://gitlab.com/test'; const url = 'https://gitlab.com/test';
......
import { setTitle } from '~/repository/utils/title'; import { setTitle, updateRefPortionOfTitle } from '~/repository/utils/title';
describe('setTitle', () => { describe('setTitle', () => {
it.each` it.each`
...@@ -13,3 +13,26 @@ describe('setTitle', () => { ...@@ -13,3 +13,26 @@ describe('setTitle', () => {
expect(document.title).toEqual(`${title} · master · GitLab Org / GitLab · GitLab`); expect(document.title).toEqual(`${title} · master · GitLab Org / GitLab · GitLab`);
}); });
}); });
describe('updateRefPortionOfTitle', () => {
const sha = 'abc';
const testCases = [
[
'updates the title with the SHA',
{ title: 'part 1 · part 2 · part 3' },
'part 1 · abc · part 3',
],
["makes no change if there's no title", { foo: null }, undefined],
[
"makes no change if the title doesn't split predictably",
{ title: 'part 1 - part 2 - part 3' },
'part 1 - part 2 - part 3',
],
];
it.each(testCases)('%s', (desc, doc, title) => {
updateRefPortionOfTitle(sha, doc);
expect(doc.title).toEqual(title);
});
});
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