Skip to content
Snippets Groups Projects
Commit fac4f50c authored by Tim Zallmann's avatar Tim Zallmann Committed by Sean McGivern
Browse files

Send resize parameters for avatars

parent dd627072
No related branches found
No related tags found
1 merge request!10495Merge Requests - Assignee
Showing
with 136 additions and 122 deletions
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 {
Loading
Loading
@@ -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;
Loading
Loading
@@ -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');
Loading
Loading
<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.
Loading
Loading
@@ -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;
Loading
Loading
Loading
Loading
@@ -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)
Loading
Loading
@@ -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.
Loading
Loading
@@ -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
Loading
Loading
Loading
Loading
@@ -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
Loading
Loading
Loading
Loading
@@ -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
Loading
Loading
Loading
Loading
@@ -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) }
Loading
Loading
Loading
Loading
@@ -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 }
Loading
Loading
Loading
Loading
@@ -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
Loading
Loading
Loading
Loading
@@ -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
Loading
Loading
Loading
Loading
@@ -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({
Loading
Loading
@@ -191,7 +182,8 @@ describe('Issue card component', () => {
name: 'user4',
username: 'user4',
avatar: 'test_image',
})];
}),
];
 
Vue.nextTick(() => done());
});
Loading
Loading
@@ -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,
Loading
Loading
@@ -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();
});
});
Loading
Loading
@@ -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();
})
Loading
Loading
Loading
Loading
@@ -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');
});
 
Loading
Loading
@@ -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', () => {
Loading
Loading
@@ -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');
});
 
Loading
Loading
@@ -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', () => {
Loading
Loading
@@ -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');
});
});
Loading
Loading
@@ -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`,
);
});
});
Loading
Loading
Loading
Loading
@@ -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;
 
Loading
Loading
@@ -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');
Loading
Loading
@@ -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`);
});
});
});
Loading
Loading
@@ -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)
Loading
Loading
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment