Commit bc4918eb authored by Sean McGivern's avatar Sean McGivern

Merge branch 'tz-frontend-avatar-resizing' into 'master'

Send resize parameters for avatars

See merge request gitlab-org/gitlab-ce!20966
parents dd627072 fac4f50c
import _ from 'underscore';
export const placeholderImage = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==';
export const placeholderImage =
'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==';
const SCROLL_THRESHOLD = 300;
export default class LazyLoader {
......@@ -48,7 +49,7 @@ export default class LazyLoader {
const visHeight = scrollTop + window.innerHeight + SCROLL_THRESHOLD;
// Loading Images which are in the current viewport or close to them
this.lazyImages = this.lazyImages.filter((selectedImage) => {
this.lazyImages = this.lazyImages.filter(selectedImage => {
if (selectedImage.getAttribute('data-src')) {
const imgBoundRect = selectedImage.getBoundingClientRect();
const imgTop = scrollTop + imgBoundRect.top;
......@@ -66,7 +67,18 @@ export default class LazyLoader {
}
static loadImage(img) {
if (img.getAttribute('data-src')) {
img.setAttribute('src', img.getAttribute('data-src'));
let imgUrl = img.getAttribute('data-src');
// Only adding width + height for avatars for now
if (imgUrl.indexOf('/avatar/') > -1 && imgUrl.indexOf('?') === -1) {
let targetWidth = null;
if (img.getAttribute('width')) {
targetWidth = img.getAttribute('width');
} else {
targetWidth = img.width;
}
if (targetWidth) imgUrl += `?width=${targetWidth}`;
}
img.setAttribute('src', imgUrl);
img.removeAttribute('data-src');
img.classList.remove('lazy');
img.classList.add('js-lazy-loaded');
......
<script>
/* This is a re-usable vue component for rendering a user avatar that
does not need to link to the user's profile. The image and an optional
tooltip can be configured by props passed to this component.
......@@ -67,7 +66,9 @@ export default {
// we provide an empty string when we use it inside user avatar link.
// In both cases we should render the defaultAvatarUrl
sanitizedSource() {
return this.imgSrc === '' || this.imgSrc === null ? defaultAvatarUrl : this.imgSrc;
let baseSrc = this.imgSrc === '' || this.imgSrc === null ? defaultAvatarUrl : this.imgSrc;
if (baseSrc.indexOf('?') === -1) baseSrc += `?width=${this.size}`;
return baseSrc;
},
resultantSrcAttribute() {
return this.lazy ? placeholderImage : this.sanitizedSource;
......
......@@ -19,7 +19,7 @@ module Avatarable
# We use avatar_path instead of overriding avatar_url because of carrierwave.
# See https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/11001/diffs#note_28659864
avatar_path(only_path: args.fetch(:only_path, true)) || super
avatar_path(only_path: args.fetch(:only_path, true), size: args[:size]) || super
end
def retrieve_upload(identifier, paths)
......@@ -40,12 +40,13 @@ module Avatarable
end
end
def avatar_path(only_path: true)
def avatar_path(only_path: true, size: nil)
return unless self[:avatar].present?
asset_host = ActionController::Base.asset_host
use_asset_host = asset_host.present?
use_authentication = respond_to?(:public?) && !public?
query_params = size&.nonzero? ? "?width=#{size}" : ""
# Avatars for private and internal groups and projects require authentication to be viewed,
# which means they can only be served by Rails, on the regular GitLab host.
......@@ -64,7 +65,7 @@ module Avatarable
url_base << gitlab_config.relative_url_root
end
url_base + avatar.local_url
url_base + avatar.local_url + query_params
end
# Path that is persisted in the tracking Upload model. Used to fetch the
......
......@@ -20,7 +20,7 @@
= link_to(admin_namespace_project_path(project.namespace, project)) do
.dash-project-avatar
.avatar-container.s40
= project_icon(project, alt: '', class: 'avatar project-avatar s40')
= project_icon(project, alt: '', class: 'avatar project-avatar s40', width: 40, height: 40)
%span.project-full-name
%span.namespace-name
- if project.namespace
......
......@@ -4,7 +4,7 @@
.context-header
= link_to project_path(@project), title: @project.name do
.avatar-container.s40.project-avatar
= project_icon(@project, alt: @project.name, class: 'avatar s40 avatar-tile')
= project_icon(@project, alt: @project.name, class: 'avatar s40 avatar-tile', width: 40, height: 40)
.sidebar-context-title
= @project.name
%ul.sidebar-top-level-items
......
......@@ -3,7 +3,7 @@
.project-home-panel.text-center{ class: ("empty-project" if empty_repo) }
.limit-container-width{ class: container_class }
.avatar-container.s70.project-avatar
= project_icon(@project, alt: @project.name, class: 'avatar s70 avatar-tile')
= project_icon(@project, alt: @project.name, class: 'avatar s70 avatar-tile', width: 70, height: 70)
%h1.project-title.qa-project-name
= @project.name
%span.visibility-icon.has-tooltip{ data: { container: 'body' }, title: visibility_icon_description(@project) }
......
......@@ -51,7 +51,7 @@
.form-group
- if @project.avatar?
.avatar-container.s160.append-bottom-15
= project_icon(@project.full_path, alt: '', class: 'avatar project-avatar s160')
= project_icon(@project.full_path, alt: '', class: 'avatar project-avatar s160', width: 160, height: 160)
- if @project.avatar_in_git
%p.light
= _("Project avatar in repository: %{link}").html_safe % { link: @project.avatar_in_git }
......
......@@ -19,7 +19,7 @@
- if project.creator && use_creator_avatar
= image_tag avatar_icon_for_user(project.creator, 40), class: "avatar s40", alt:''
- else
= project_icon(project, alt: '', class: 'avatar project-avatar s40')
= project_icon(project, alt: '', class: 'avatar project-avatar s40', width: 40, height: 40)
.project-details
%h3.prepend-top-0.append-bottom-0
= link_to project_path(project), class: 'text-plain' do
......
......@@ -15,7 +15,7 @@ describe 'User uploads avatar to profile' do
visit user_path(user)
expect(page).to have_selector(%Q(img[data-src$="/uploads/-/system/user/avatar/#{user.id}/dk.png"]))
expect(page).to have_selector(%Q(img[data-src$="/uploads/-/system/user/avatar/#{user.id}/dk.png?width=90"]))
# Cheating here to verify something that isn't user-facing, but is important
expect(user.reload.avatar.file).to exist
......
......@@ -69,109 +69,100 @@ describe('Issue card component', () => {
});
it('renders issue title', () => {
expect(
component.$el.querySelector('.board-card-title').textContent,
).toContain(issue.title);
expect(component.$el.querySelector('.board-card-title').textContent).toContain(issue.title);
});
it('includes issue base in link', () => {
expect(
component.$el.querySelector('.board-card-title a').getAttribute('href'),
).toContain('/test');
expect(component.$el.querySelector('.board-card-title a').getAttribute('href')).toContain(
'/test',
);
});
it('includes issue title on link', () => {
expect(
component.$el.querySelector('.board-card-title a').getAttribute('title'),
).toBe(issue.title);
expect(component.$el.querySelector('.board-card-title a').getAttribute('title')).toBe(
issue.title,
);
});
it('does not render confidential icon', () => {
expect(
component.$el.querySelector('.fa-eye-flash'),
).toBeNull();
expect(component.$el.querySelector('.fa-eye-flash')).toBeNull();
});
it('renders confidential icon', (done) => {
it('renders confidential icon', done => {
component.issue.confidential = true;
Vue.nextTick(() => {
expect(
component.$el.querySelector('.confidential-icon'),
).not.toBeNull();
expect(component.$el.querySelector('.confidential-icon')).not.toBeNull();
done();
});
});
it('renders issue ID with #', () => {
expect(
component.$el.querySelector('.board-card-number').textContent,
).toContain(`#${issue.id}`);
expect(component.$el.querySelector('.board-card-number').textContent).toContain(`#${issue.id}`);
});
describe('assignee', () => {
it('does not render assignee', () => {
expect(
component.$el.querySelector('.board-card-assignee .avatar'),
).toBeNull();
expect(component.$el.querySelector('.board-card-assignee .avatar')).toBeNull();
});
describe('exists', () => {
beforeEach((done) => {
beforeEach(done => {
component.issue.assignees = [user];
Vue.nextTick(() => done());
});
it('renders assignee', () => {
expect(
component.$el.querySelector('.board-card-assignee .avatar'),
).not.toBeNull();
expect(component.$el.querySelector('.board-card-assignee .avatar')).not.toBeNull();
});
it('sets title', () => {
expect(
component.$el.querySelector('.board-card-assignee img').getAttribute('data-original-title'),
component.$el
.querySelector('.board-card-assignee img')
.getAttribute('data-original-title'),
).toContain(`Assigned to ${user.name}`);
});
it('sets users path', () => {
expect(
component.$el.querySelector('.board-card-assignee a').getAttribute('href'),
).toBe('/test');
expect(component.$el.querySelector('.board-card-assignee a').getAttribute('href')).toBe(
'/test',
);
});
it('renders avatar', () => {
expect(
component.$el.querySelector('.board-card-assignee img'),
).not.toBeNull();
expect(component.$el.querySelector('.board-card-assignee img')).not.toBeNull();
});
});
describe('assignee default avatar', () => {
beforeEach((done) => {
component.issue.assignees = [new ListAssignee({
id: 1,
name: 'testing 123',
username: 'test',
}, 'default_avatar')];
beforeEach(done => {
component.issue.assignees = [
new ListAssignee(
{
id: 1,
name: 'testing 123',
username: 'test',
},
'default_avatar',
),
];
Vue.nextTick(done);
});
it('displays defaults avatar if users avatar is null', () => {
expect(
component.$el.querySelector('.board-card-assignee img'),
).not.toBeNull();
expect(
component.$el.querySelector('.board-card-assignee img').getAttribute('src'),
).toBe('default_avatar');
expect(component.$el.querySelector('.board-card-assignee img')).not.toBeNull();
expect(component.$el.querySelector('.board-card-assignee img').getAttribute('src')).toBe(
'default_avatar?width=20',
);
});
});
});
describe('multiple assignees', () => {
beforeEach((done) => {
beforeEach(done => {
component.issue.assignees = [
user,
new ListAssignee({
......@@ -191,7 +182,8 @@ describe('Issue card component', () => {
name: 'user4',
username: 'user4',
avatar: 'test_image',
})];
}),
];
Vue.nextTick(() => done());
});
......@@ -201,26 +193,30 @@ describe('Issue card component', () => {
});
describe('more than four assignees', () => {
beforeEach((done) => {
component.issue.assignees.push(new ListAssignee({
id: 5,
name: 'user5',
username: 'user5',
avatar: 'test_image',
}));
beforeEach(done => {
component.issue.assignees.push(
new ListAssignee({
id: 5,
name: 'user5',
username: 'user5',
avatar: 'test_image',
}),
);
Vue.nextTick(() => done());
});
it('renders more avatar counter', () => {
expect(component.$el.querySelector('.board-card-assignee .avatar-counter').innerText).toEqual('+2');
expect(
component.$el.querySelector('.board-card-assignee .avatar-counter').innerText,
).toEqual('+2');
});
it('renders three assignees', () => {
expect(component.$el.querySelectorAll('.board-card-assignee .avatar').length).toEqual(3);
});
it('renders 99+ avatar counter', (done) => {
it('renders 99+ avatar counter', done => {
for (let i = 5; i < 104; i += 1) {
const u = new ListAssignee({
id: i,
......@@ -232,7 +228,9 @@ describe('Issue card component', () => {
}
Vue.nextTick(() => {
expect(component.$el.querySelector('.board-card-assignee .avatar-counter').innerText).toEqual('99+');
expect(
component.$el.querySelector('.board-card-assignee .avatar-counter').innerText,
).toEqual('99+');
done();
});
});
......@@ -240,59 +238,51 @@ describe('Issue card component', () => {
});
describe('labels', () => {
beforeEach((done) => {
beforeEach(done => {
component.issue.addLabel(label1);
Vue.nextTick(() => done());
});
it('renders list label', () => {
expect(
component.$el.querySelectorAll('.badge').length,
).toBe(2);
expect(component.$el.querySelectorAll('.badge').length).toBe(2);
});
it('renders label', () => {
const nodes = [];
component.$el.querySelectorAll('.badge').forEach((label) => {
component.$el.querySelectorAll('.badge').forEach(label => {
nodes.push(label.getAttribute('data-original-title'));
});
expect(
nodes.includes(label1.description),
).toBe(true);
expect(nodes.includes(label1.description)).toBe(true);
});
it('sets label description as title', () => {
expect(
component.$el.querySelector('.badge').getAttribute('data-original-title'),
).toContain(label1.description);
expect(component.$el.querySelector('.badge').getAttribute('data-original-title')).toContain(
label1.description,
);
});
it('sets background color of button', () => {
const nodes = [];
component.$el.querySelectorAll('.badge').forEach((label) => {
component.$el.querySelectorAll('.badge').forEach(label => {
nodes.push(label.style.backgroundColor);
});
expect(
nodes.includes(label1.color),
).toBe(true);
expect(nodes.includes(label1.color)).toBe(true);
});
it('does not render label if label does not have an ID', (done) => {
component.issue.addLabel(new ListLabel({
title: 'closed',
}));
it('does not render label if label does not have an ID', done => {
component.issue.addLabel(
new ListLabel({
title: 'closed',
}),
);
Vue.nextTick()
.then(() => {
expect(
component.$el.querySelectorAll('.badge').length,
).toBe(2);
expect(
component.$el.textContent,
).not.toContain('closed');
expect(component.$el.querySelectorAll('.badge').length).toBe(2);
expect(component.$el.textContent).not.toContain('closed');
done();
})
......
......@@ -35,7 +35,9 @@ describe('Pipeline Url Component', () => {
},
}).$mount();
expect(component.$el.querySelector('.js-pipeline-url-link').getAttribute('href')).toEqual('foo');
expect(component.$el.querySelector('.js-pipeline-url-link').getAttribute('href')).toEqual(
'foo',
);
expect(component.$el.querySelector('.js-pipeline-url-link span').textContent).toEqual('#1');
});
......@@ -61,11 +63,11 @@ describe('Pipeline Url Component', () => {
const image = component.$el.querySelector('.js-pipeline-url-user img');
expect(
component.$el.querySelector('.js-pipeline-url-user').getAttribute('href'),
).toEqual(mockData.pipeline.user.web_url);
expect(component.$el.querySelector('.js-pipeline-url-user').getAttribute('href')).toEqual(
mockData.pipeline.user.web_url,
);
expect(image.getAttribute('data-original-title')).toEqual(mockData.pipeline.user.name);
expect(image.getAttribute('src')).toEqual(mockData.pipeline.user.avatar_url);
expect(image.getAttribute('src')).toEqual(`${mockData.pipeline.user.avatar_url}?width=20`);
});
it('should render "API" when no user is provided', () => {
......@@ -100,7 +102,9 @@ describe('Pipeline Url Component', () => {
}).$mount();
expect(component.$el.querySelector('.js-pipeline-url-latest').textContent).toContain('latest');
expect(component.$el.querySelector('.js-pipeline-url-yaml').textContent).toContain('yaml invalid');
expect(component.$el.querySelector('.js-pipeline-url-yaml').textContent).toContain(
'yaml invalid',
);
expect(component.$el.querySelector('.js-pipeline-url-stuck').textContent).toContain('stuck');
});
......@@ -121,9 +125,9 @@ describe('Pipeline Url Component', () => {
},
}).$mount();
expect(
component.$el.querySelector('.js-pipeline-url-autodevops').textContent.trim(),
).toEqual('Auto DevOps');
expect(component.$el.querySelector('.js-pipeline-url-autodevops').textContent.trim()).toEqual(
'Auto DevOps',
);
});
it('should render error badge when pipeline has a failure reason set', () => {
......@@ -142,6 +146,8 @@ describe('Pipeline Url Component', () => {
}).$mount();
expect(component.$el.querySelector('.js-pipeline-url-failure').textContent).toContain('error');
expect(component.$el.querySelector('.js-pipeline-url-failure').getAttribute('data-original-title')).toContain('some reason');
expect(
component.$el.querySelector('.js-pipeline-url-failure').getAttribute('data-original-title'),
).toContain('some reason');
});
});
......@@ -27,7 +27,7 @@ describe('issue placeholder system note component', () => {
userDataMock.path,
);
expect(vm.$el.querySelector('.user-avatar-link img').getAttribute('src')).toEqual(
userDataMock.avatar_url,
`${userDataMock.avatar_url}?width=40`,
);
});
});
......
......@@ -12,7 +12,7 @@ const DEFAULT_PROPS = {
tooltipPlacement: 'bottom',
};
describe('User Avatar Image Component', function () {
describe('User Avatar Image Component', function() {
let vm;
let UserAvatarImage;
......@@ -20,37 +20,37 @@ describe('User Avatar Image Component', function () {
UserAvatarImage = Vue.extend(userAvatarImage);
});
describe('Initialization', function () {
beforeEach(function () {
describe('Initialization', function() {
beforeEach(function() {
vm = mountComponent(UserAvatarImage, {
...DEFAULT_PROPS,
}).$mount();
});
it('should return a defined Vue component', function () {
it('should return a defined Vue component', function() {
expect(vm).toBeDefined();
});
it('should have <img> as a child element', function () {
it('should have <img> as a child element', function() {
expect(vm.$el.tagName).toBe('IMG');
expect(vm.$el.getAttribute('src')).toBe(DEFAULT_PROPS.imgSrc);
expect(vm.$el.getAttribute('data-src')).toBe(DEFAULT_PROPS.imgSrc);
expect(vm.$el.getAttribute('src')).toBe(`${DEFAULT_PROPS.imgSrc}?width=99`);
expect(vm.$el.getAttribute('data-src')).toBe(`${DEFAULT_PROPS.imgSrc}?width=99`);
expect(vm.$el.getAttribute('alt')).toBe(DEFAULT_PROPS.imgAlt);
});
it('should properly compute tooltipContainer', function () {
it('should properly compute tooltipContainer', function() {
expect(vm.tooltipContainer).toBe('body');
});
it('should properly render tooltipContainer', function () {
it('should properly render tooltipContainer', function() {
expect(vm.$el.getAttribute('data-container')).toBe('body');
});
it('should properly compute avatarSizeClass', function () {
it('should properly compute avatarSizeClass', function() {
expect(vm.avatarSizeClass).toBe('s99');
});
it('should properly render img css', function () {
it('should properly render img css', function() {
const { classList } = vm.$el;
const containsAvatar = classList.contains('avatar');
const containsSizeClass = classList.contains('s99');
......@@ -64,21 +64,21 @@ describe('User Avatar Image Component', function () {
});
});
describe('Initialization when lazy', function () {
beforeEach(function () {
describe('Initialization when lazy', function() {
beforeEach(function() {
vm = mountComponent(UserAvatarImage, {
...DEFAULT_PROPS,
lazy: true,
}).$mount();
});
it('should add lazy attributes', function () {
it('should add lazy attributes', function() {
const { classList } = vm.$el;
const lazyClass = classList.contains('lazy');
expect(lazyClass).toBe(true);
expect(vm.$el.getAttribute('src')).toBe(placeholderImage);
expect(vm.$el.getAttribute('data-src')).toBe(DEFAULT_PROPS.imgSrc);
expect(vm.$el.getAttribute('data-src')).toBe(`${DEFAULT_PROPS.imgSrc}?width=99`);
});
});
});
......@@ -43,6 +43,10 @@ describe Avatarable do
expect(project.avatar_path(only_path: only_path)).to eq(avatar_path)
end
it 'returns the expected avatar path with width parameter' do
expect(project.avatar_path(only_path: only_path, size: 128)).to eq(avatar_path + "?width=128")
end
context "when avatar is stored remotely" do
before do
stub_uploads_object_storage(AvatarUploader)
......
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