Refactor/user avatar to principal (#9069)

* Fix some ium stylings

* Extend create service to also test with empty names

* Add PrincipalLike type to pass around non-created placeholder refs

* Add icon-context

* Move principal rendering to its own module

* Fix emit create new placeholder to principals

* Revert op-principal for now

* Add memberships form API to properly post

* Fix types for returned principals

* Move untilDestroyed in role

* Filter input if not string in role-search

* Pass correct inputs to success component

* Return principal after saving membership

* Fix small stuff around  the ium

* Fix the way HalResources are selected and passed

* Move principal module to be exported by common

* Disable quotemark in tslint until eslint is enabled

* Fix image path in success

* Adapt modal to run all steps in one within the modal helper component

* Several fixes to modals

* Fix ium success component styles,
* Registration modal y-overflow
* Add SMTP parameters to .env.example

* Add disabled option to op-option-list, disabled placeholder users for non-ee instances

* Add correct ee link to placeholder user option

* Fix build

* Removed unused sass files

* Fix principal search not found indicator, added placeholder add image

* Fix enterprise edition url, use dirty instead of touched check

* Use backend class names for frontend principal types

* Fix duplicate import and principal type usage

* Also disable banners if with_ee is present in test

* Extend specs for placeholders

* Fix disabled attribute

* Extend spec WIP

* Improved inline-validation styles, fixed more PrincipalType usages

* Add group happy path test, fix more PrincipalType usage

* Fix a translation

* Revert line deletion

* Rewrite same spec examples into shared examples

* Fix name of shared example

* Dont run assets:clean to remove angular assets

* Output whether assets are there at all

* Update user-avatar usages to principal

* Fix some op-principal usages

* Fix principal typing

* Remove ls of non-existent directory

* Rename more user-avatar instances

* Avatars now render correctly

* Fix an op-principal instance, default to avatar class for avatars

* Always add principal id to default principal tag

* Small fixes to op-principal

* Fix multiline user display

* Dirty fix for capybara click events

* Update avatar sizing

* Fixed some specs

* Fix unit specs

* Added op-link styles

* Fix add placeholder image link

* Remove byebug debugger statements

Co-authored-by: Oliver Günther <mail@oliverguenther.de>
pull/9099/head
Benjamin Bädorf 4 years ago committed by GitHub
parent b609964818
commit beb4e6ba50
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 2
      .gitignore
  2. 2
      docker/ci/entrypoint.sh
  3. 94
      frontend/src/app/components/user/user-avatar/user-avatar-renderer.service.ts
  4. 97
      frontend/src/app/components/user/user-avatar/user-avatar.component.spec.ts
  5. 56
      frontend/src/app/components/user/user-avatar/user-avatar.component.ts
  6. 23
      frontend/src/app/components/wp-activity/revision/revision-activity.component.html
  7. 10
      frontend/src/app/components/wp-activity/user/user-activity.component.html
  8. 12
      frontend/src/app/components/wp-card-view/wp-single-card/wp-single-card.component.html
  9. 11
      frontend/src/app/components/wp-single-view-tabs/watchers-tab/wp-watcher-entry.html
  10. 4
      frontend/src/app/global-dynamic-components.const.ts
  11. 10
      frontend/src/app/modules/autocompleter/user-autocompleter/user-autocompleter.component.html
  12. 7
      frontend/src/app/modules/boards/board/board-actions/assignee/assignee-board-header.html
  13. 2
      frontend/src/app/modules/boards/board/board-actions/assignee/assignee-board-header.sass
  14. 16
      frontend/src/app/modules/common/link/link.sass
  15. 1
      frontend/src/app/modules/common/openproject-common.module.sass
  16. 7
      frontend/src/app/modules/common/openproject-common.module.ts
  17. 18
      frontend/src/app/modules/fields/display/field-types/multiple-lines-user-display-field.module.ts
  18. 20
      frontend/src/app/modules/fields/display/field-types/multiple-user-display-field.module.ts
  19. 12
      frontend/src/app/modules/fields/display/field-types/user-display-field.module.ts
  20. 9
      frontend/src/app/modules/global_search/input/global-search-input.component.html
  21. 7
      frontend/src/app/modules/grids/widgets/members/members.component.html
  22. 10
      frontend/src/app/modules/grids/widgets/news/news.component.html
  23. 4
      frontend/src/app/modules/grids/widgets/news/news.component.spec.ts
  24. 3
      frontend/src/app/modules/invite-user-modal/invite-user-modal.types.ts
  25. 2
      frontend/src/app/modules/invite-user-modal/principal/principal-search.component.ts
  26. 4
      frontend/src/app/modules/invite-user-modal/principal/principal.component.ts
  27. 2
      frontend/src/app/modules/invite-user-modal/summary/summary.component.ts
  28. 103
      frontend/src/app/modules/principal/principal-renderer.service.ts
  29. 5
      frontend/src/app/modules/principal/principal-types.ts
  30. 42
      frontend/src/app/modules/principal/principal.component.ts
  31. 4
      frontend/src/assets/images/invite-user-modal/placeholder-added.svg
  32. 40
      frontend/src/global_styles/content/_user.sass
  33. 2
      frontend/src/global_styles/content/_watchers.sass
  34. 45
      modules/avatars/lib/open_project/avatars/patches/avatar_helper_patch.rb
  35. 41
      modules/avatars/spec/helpers/avatar_helper_spec.rb
  36. 4
      modules/boards/spec/features/action_boards/assignee_board_spec.rb

2
.gitignore vendored

@ -46,6 +46,8 @@ npm-debug.log*
# Ignore Ctags files
/tags
/tags.lock
/tags.temp
# Ignore RubyMine files
/.idea

@ -72,7 +72,7 @@ fi
if [ "$1" == "run-features" ]; then
shift
execute "cd frontend; npm install ; cd -"
execute "bundle exec rake assets:precompile assets:clean"
execute "bundle exec rake assets:precompile"
execute "cp -rp config/frontend_assets.manifest.json public/assets/frontend_assets.manifest.json"
if ! execute "time bundle exec rake parallel:features" ; then
execute "cat tmp/parallel_summary.log"

@ -1,94 +0,0 @@
import { Injectable } from "@angular/core";
import { PathHelperService } from "core-app/modules/common/path-helper/path-helper.service";
import { ColorsService } from "core-app/modules/common/colors/colors.service";
import { APIV3Service } from "core-app/modules/apiv3/api-v3.service";
export interface UserLike {
name:string;
id:string|number|null;
}
@Injectable({ providedIn: 'root' })
export class UserAvatarRendererService {
constructor(private pathHelper:PathHelperService,
private apiV3Service:APIV3Service,
private colors:ColorsService) {
}
renderMultiple(container:HTMLElement,
users:UserLike[],
renderName = true,
multiLine = false) {
const span = document.createElement('span');
for (let i = 0; i < users.length; i++) {
const avatar = document.createElement('span');
if (multiLine) {
avatar.classList.add('user-avatar--multi-line');
}
this.render(avatar, users[i], renderName);
if (!multiLine && i < users.length - 1) {
const sep = document.createElement('span');
sep.textContent = ', ';
avatar.appendChild(sep);
}
span.appendChild(avatar);
}
container.appendChild(span);
}
render(container:HTMLElement,
user:UserLike,
renderName = true,
classes = 'avatar-medium'):void {
const userInitials = this.getInitials(user.name);
const colorCode = this.colors.toHsl(user.name);
let fallback = document.createElement('div');
fallback.className = classes;
fallback.classList.add('avatar-default');
fallback.textContent = userInitials;
fallback.style.background = colorCode;
container.appendChild(fallback);
if (renderName) {
const name = document.createElement('span');
name.textContent = user.name;
container.appendChild(name);
}
// Avoid using the image when ID is null
if (!user.id) {
return;
}
const image = new Image();
image.className = classes;
image.classList.add('avatar--fallback');
image.src = this.apiV3Service.users.id(user.id).avatar.toString();
image.title = user.name;
image.alt = user.name;
image.onload = function () {
fallback.replaceWith(image);
(fallback as any) = undefined;
};
}
private getInitials(name:string) {
const characters = [...name];
const lastSpace = name.lastIndexOf(' ');
const first = characters[0]?.toUpperCase();
const last = name[lastSpace + 1]?.toUpperCase();
return [first, last].join("");
}
}

@ -1,97 +0,0 @@
//-- copyright
// OpenProject is an open source project management software.
// Copyright (C) 2012-2021 the OpenProject GmbH
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License version 3.
//
// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
// Copyright (C) 2006-2013 Jean-Philippe Lang
// Copyright (C) 2010-2013 the ChiliProject Team
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License
// as published by the Free Software Foundation; either version 2
// of the License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program; if not, write to the Free Software
// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
//
// See docs/COPYRIGHT.rdoc for more details.
//++
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { PathHelperService } from 'core-app/modules/common/path-helper/path-helper.service';
import { UserAvatarComponent } from "core-components/user/user-avatar/user-avatar.component";
import { APIV3Service } from "core-app/modules/apiv3/api-v3.service";
import { States } from "core-components/states.service";
describe('UserAvatar component test', () => {
let app:UserAvatarComponent;
let fixture:ComponentFixture<UserAvatarComponent>;
let element:HTMLElement;
let user:any;
beforeEach(waitForAsync(() => {
// noinspection JSIgnoredPromiseFromCall
TestBed.configureTestingModule({
declarations: [
UserAvatarComponent
],
providers: [
States,
APIV3Service,
PathHelperService
]
}).compileComponents();
fixture = TestBed.createComponent(UserAvatarComponent);
app = fixture.debugElement.componentInstance;
element = fixture.elementRef.nativeElement;
}));
describe('Regular initials', () => {
beforeEach(waitForAsync(() => {
user = {
id: 1,
name: 'First Last',
};
app.user = user;
element.dataset.useFallback = 'true';
app.ngAfterViewInit();
fixture.detectChanges();
}));
it('should render the fallback avatar', function () {
const link = element.querySelector('.avatar-default')!;
expect(link.textContent).toEqual('FL');
});
});
describe('Emoji initials', () => {
beforeEach(waitForAsync(() => {
user = {
id: 1,
name: "\uFE0F Foo Bar",
};
app.user = user;
element.dataset.useFallback = 'true';
app.ngAfterViewInit();
fixture.detectChanges();
}));
it('should render the fallback avatar', function () {
const link = element.querySelector('.avatar-default')!;
expect(link.textContent).toEqual('\uFe0F' + 'B');
});
});
});

@ -1,56 +0,0 @@
//-- copyright
// OpenProject is an open source project management software.
// Copyright (C) 2012-2021 the OpenProject GmbH
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License version 3.
//
// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
// Copyright (C) 2006-2013 Jean-Philippe Lang
// Copyright (C) 2010-2013 the ChiliProject Team
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License
// as published by the Free Software Foundation; either version 2
// of the License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program; if not, write to the Free Software
// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
//
// See docs/COPYRIGHT.rdoc for more details.
//++
import { UserResource } from 'core-app/modules/hal/resources/user-resource';
import { AfterViewInit, ChangeDetectionStrategy, Component, ElementRef, Input } from "@angular/core";
import { PathHelperService } from "core-app/modules/common/path-helper/path-helper.service";
import { UserAvatarRendererService } from "core-components/user/user-avatar/user-avatar-renderer.service";
export const userAvatarSelector = 'user-avatar';
@Component({
selector: userAvatarSelector,
changeDetection: ChangeDetectionStrategy.OnPush,
template: ''
})
export class UserAvatarComponent implements AfterViewInit {
/** If coming from angular, pass a user resource if available */
@Input() public user?:UserResource;
constructor(protected elementRef:ElementRef,
protected avatarRenderer:UserAvatarRendererService,
protected pathHelper:PathHelperService) {
}
public ngAfterViewInit() {
const element = this.elementRef.nativeElement;
const user = this.user || { name: element.dataset.userName!, id: element.dataset.userId };
this.avatarRenderer.render(element, user, false, element.dataset.classList);
}
}

@ -6,17 +6,20 @@
</activity-link>
</div>
<user-avatar *ngIf="activity.author && userName"
[attr.data-user-name]="userName"
[attr.data-user-id]="userId"
data-class-list="avatar">
</user-avatar>
<op-principal
*ngIf="activity.author && userName"
[attr.data-principal-id]="userId"
[attr.data-principal-name]="userName"
data-principal-type="user"
data-hide-name="true"
></op-principal>
<user-avatar *ngIf="!activity.author && userName"
[attr.data-user-name]="userName"
[attr.data-use-fallback]="true"
data-class-list="avatar">
</user-avatar>
<op-principal
*ngIf="!activity.author && userName"
[attr.data-principal-name]="userName"
data-principal-type="user"
data-hide-name="true"
></op-principal>
<span class="user" *ngIf="userActive">
<a [attr.href]="userPath"

@ -2,11 +2,11 @@
*ngIf="workPackage"
(mouseenter)="focus()"
(mouseleave)="blur()">
<user-avatar *ngIf="userName"
[attr.data-user-name]="userName"
[attr.data-user-id]="userId"
data-class-list="avatar">
</user-avatar>
<op-principal
*ngIf="userName"
[principal]="user"
[hide-name]="true"
></op-principal>
<span class="user">
<user-link [user]="user"></user-link>

@ -62,10 +62,12 @@
[workPackage]="workPackage"
class="wp-card--status">
</wp-status-button>
<user-avatar *ngIf="workPackage.assignee"
[user]="workPackage.assignee"
data-class-list="avatar-mini"
class="wp-card--assignee">
</user-avatar>
<op-principal
*ngIf="workPackage.assignee"
[principal]="workPackage.assignee"
[hide-name]="true"
size="mini"
class="wp-card--assignee"
></op-principal>
</div>
</div>

@ -2,10 +2,13 @@
<span class="form--selected-value">
<span [ngClass]="{'deleting': deleting }">
<a [attr.href]="watcher.showUser.href">
<user-avatar [attr.data-user-name]="watcher.name"
[attr.data-user-id]="watcher.id"
data-class-list="avatar-mini">
</user-avatar>
<op-principal
[attr.data-principal-id]="watcher.id"
[attr.data-principal-name]="watcher.name"
data-principal-type="user"
data-hide-name="true"
data-size="avatar-mini"
></op-principal>
<span class="work-package--watcher-name" [textContent]="watcher.name"></span>
</a>
</span>

@ -50,7 +50,7 @@ import {
PersistentToggleComponent,
persistentToggleSelector
} from "core-app/modules/common/persistent-toggle/persistent-toggle.component";
import { UserAvatarComponent, userAvatarSelector } from "core-components/user/user-avatar/user-avatar.component";
import {OpPrincipalComponent, principalSelector} from "core-app/modules/principal/principal.component";
import {
HideSectionLinkComponent,
hideSectionLinkSelector
@ -175,7 +175,7 @@ export const globalDynamicComponents:OptionalBootstrapDefinition[] = [
{ selector: adminTypeFormConfigurationSelector, cls: TypeFormConfigurationComponent, },
{ selector: ckeditorAugmentedTextareaSelector, cls: CkeditorAugmentedTextareaComponent, embeddable: true },
{ selector: persistentToggleSelector, cls: PersistentToggleComponent },
{ selector: userAvatarSelector, cls: UserAvatarComponent },
{ selector: principalSelector, cls: OpPrincipalComponent },
{ selector: hideSectionLinkSelector, cls: HideSectionLinkComponent },
{ selector: showSectionDropdownSelector, cls: ShowSectionDropdownComponent },
{ selector: addSectionDropdownSelector, cls: AddSectionDropdownComponent },

@ -11,10 +11,12 @@
[appendTo]="appendTo"
[multiple]="multiple" >
<ng-template ng-option-tmp let-item="item" let-index="index">
<user-avatar *ngIf="item && item.href"
[user]="item"
data-class-list="avatar-mini">
</user-avatar>
<op-principal
*ngIf="item && item.href"
[principal]="item"
[hide-name]="true"
size="mini"
></op-principal>
{{ item.name }}
</ng-template>
</ng-select>

@ -1,5 +1,10 @@
<div class="assignee-board-header" *ngIf="user">
<user-avatar *ngIf="user.id" [user]="user" data-class-list="avatar-mini"></user-avatar>
<op-principal
*ngIf="user.id"
[principal]="user"
[hide-name]="true"
size="mini"
></op-principal>
<h2 class="editable-toolbar-title--fixed">
<small [textContent]="text.assignee"></small>
<br/>

@ -1,7 +1,7 @@
.assignee-board-header
display: flex
user-avatar
op-principal
align-self: center
padding-right: 5px

@ -0,0 +1,16 @@
.op-link
color: var(--content-link-color)
text-decoration: none
background: 0
border: 0
font: inherit
cursor: pointer
&:hover,
&:active
color: var(--content-link-hover-active-color)
text-decoration: underline
&:focus
color: var(--content-link-hover-active-color)

@ -1,4 +1,5 @@
@import './input/input'
@import './link/link'
@import './form-field/form-field'
@import './option-list/option-list'
@import './export-options/export-options'

@ -48,7 +48,6 @@ import {TablePaginationComponent} from 'core-components/table-pagination/table-p
import {SortHeaderDirective} from 'core-components/wp-table/sort-header/sort-header.directive';
import {ZenModeButtonComponent} from 'core-components/wp-buttons/zen-mode-toggle-button/zen-mode-toggle-button.component';
import {OPContextMenuComponent} from 'core-components/op-context-menu/op-context-menu.component';
import {UserAvatarComponent} from 'core-components/user/user-avatar/user-avatar.component';
import {EnterpriseBannerComponent} from 'core-components/enterprise-banner/enterprise-banner.component';
import {EnterpriseBannerBootstrapComponent} from 'core-components/enterprise-banner/enterprise-banner-bootstrap.component';
import {HomescreenNewFeaturesBlockComponent} from 'core-components/homescreen/blocks/new-features.component';
@ -178,9 +177,6 @@ export function bootstrapModule(injector:Injector) {
EditableToolbarTitleComponent,
// User Avatar
UserAvatarComponent,
// Enterprise Edition
EnterpriseBannerComponent,
@ -236,9 +232,6 @@ export function bootstrapModule(injector:Injector) {
EditableToolbarTitleComponent,
// User Avatar
UserAvatarComponent,
PersistentToggleComponent,
HideSectionLinkComponent,
ShowSectionDropdownComponent,

@ -26,13 +26,13 @@
// See docs/COPYRIGHT.rdoc for more details.
//++
import { ResourcesDisplayField } from "./resources-display-field.module";
import { UserResource } from "core-app/modules/hal/resources/user-resource";
import { InjectField } from "core-app/helpers/angular/inject-field.decorator";
import { UserAvatarRendererService } from "core-components/user/user-avatar/user-avatar-renderer.service";
import {ResourcesDisplayField} from "./resources-display-field.module";
import {UserResource} from "core-app/modules/hal/resources/user-resource";
import {InjectField} from "core-app/helpers/angular/inject-field.decorator";
import {PrincipalRendererService} from "core-app/modules/principal/principal-renderer.service";
export class MultipleLinesUserFieldModule extends ResourcesDisplayField {
@InjectField() avatarRenderer:UserAvatarRendererService;
@InjectField() principalRenderer:PrincipalRendererService;
public render(element:HTMLElement, displayText:string):void {
const values = this.attribute;
@ -49,6 +49,12 @@ export class MultipleLinesUserFieldModule extends ResourcesDisplayField {
}
protected renderValues(values:UserResource[], element:HTMLElement) {
this.avatarRenderer.renderMultiple(element, values, true, true);
this.principalRenderer.renderMultiple(
element,
values,
{ hide: false, link: false },
{ hide: false, size: 'medium' },
true,
);
}
}

@ -26,14 +26,14 @@
// See docs/COPYRIGHT.rdoc for more details.
//++
import { ResourcesDisplayField } from "./resources-display-field.module";
import { UserResource } from "core-app/modules/hal/resources/user-resource";
import { InjectField } from "core-app/helpers/angular/inject-field.decorator";
import { UserAvatarRendererService } from "core-components/user/user-avatar/user-avatar-renderer.service";
import { cssClassCustomOption } from "core-app/modules/fields/display/display-field.module";
import {ResourcesDisplayField} from "./resources-display-field.module";
import {UserResource} from "core-app/modules/hal/resources/user-resource";
import {InjectField} from "core-app/helpers/angular/inject-field.decorator";
import {PrincipalRendererService} from "core-app/modules/principal/principal-renderer.service";
import {cssClassCustomOption} from "core-app/modules/fields/display/display-field.module";
export class MultipleUserFieldModule extends ResourcesDisplayField {
@InjectField() avatarRenderer:UserAvatarRendererService;
@InjectField() principalRenderer:PrincipalRendererService;
public render(element:HTMLElement, displayText:string):void {
const names = this.value;
@ -74,6 +74,12 @@ export class MultipleUserFieldModule extends ResourcesDisplayField {
public renderAbridgedValues(element:HTMLElement, values:UserResource[]) {
const valueForDisplay = _.take(values, 2);
this.avatarRenderer.renderMultiple(element, valueForDisplay);
this.principalRenderer.renderMultiple(
element,
valueForDisplay,
{ hide: false, link: false },
{ hide: false, size: 'medium' },
false,
);
}
}

@ -26,12 +26,12 @@
// See docs/COPYRIGHT.rdoc for more details.
//++
import { DisplayField } from "core-app/modules/fields/display/display-field.module";
import { InjectField } from "core-app/helpers/angular/inject-field.decorator";
import { UserAvatarRendererService } from "core-components/user/user-avatar/user-avatar-renderer.service";
import {DisplayField} from "core-app/modules/fields/display/display-field.module";
import {InjectField} from "core-app/helpers/angular/inject-field.decorator";
import {PrincipalRendererService} from "core-app/modules/principal/principal-renderer.service";
export class UserDisplayField extends DisplayField {
@InjectField() avatarRenderer:UserAvatarRendererService;
@InjectField() principalRenderer:PrincipalRendererService;
public get value() {
if (this.schema) {
@ -45,9 +45,11 @@ export class UserDisplayField extends DisplayField {
if (this.placeholder === displayText) {
this.renderEmpty(element);
} else {
this.avatarRenderer.render(
this.principalRenderer.render(
element,
this.attribute,
{ hide: false, link: false },
{ hide: false, size: 'medium' }
);
}
}

@ -44,10 +44,11 @@
(click)="redirectToWp(item.id, $event)"
style="line-height: 1">
<div class="global-search--option-wrapper">
<user-avatar
[user]="item.author"
data-class-list="avatar global-search-author-avatar hidden-for-mobile">
</user-avatar>
<op-principal
[hide-name]="true"
[principal]="item.author"
class-list="avatar global-search-author-avatar hidden-for-mobile"
></op-principal>
<span class="global-search--wp-subject">
{{item.subject}}

@ -21,10 +21,9 @@
<div class="attributes-map--value">
<ng-container *ngFor="let principal of usersByRole.users; let last = last">
<op-principal
[principal]="principal"
avatarClasses="avatar avatar-mini -spaced"
>
</op-principal>
[principal]="principal"
size="mini"
></op-principal>
<ng-container *ngIf="!last">, </ng-container>
</ng-container>
</div>

@ -15,10 +15,12 @@
<li class="-widget-box--arrow-multiline"
*ngFor="let news of entries">
<div class="news-project">
<user-avatar *ngIf="news.author"
[user]="news.author"
data-class-list="avatar news-author-avatar hidden-for-mobile">
</user-avatar>
<op-principal
*ngIf="news.author"
[principal]="news.author"
[hide-name]="true"
avatar-classes="avatar news-author-avatar hidden-for-mobile"
></op-principal>
<div>
<a [href]="newsProjectPath(news)"
[textContent]="newsProjectName(news)">

@ -106,11 +106,11 @@ describe('shows news', () => {
});
}));
it('should Not add the user-avatar component into DOM', waitForAsync(() => {
it('should Not add the op-principal component into DOM', waitForAsync(() => {
fixture.detectChanges();
fixture.whenStable().then(() => {
const newsItem = document.querySelector('user-avatar');
const newsItem = document.querySelector('op-principal');
expect(document.contains(newsItem)).toBeTruthy();
});

@ -1,3 +0,0 @@
import {HalResource} from "core-app/modules/hal/resources/hal-resource";
export type PrincipalLike = HalResource|{ name: string};

@ -14,7 +14,7 @@ import {ApiV3FilterBuilder} from "core-components/api/api-v3/api-v3-filter-build
import {I18nService} from "core-app/modules/common/i18n/i18n.service";
import {UntilDestroyedMixin} from "core-app/helpers/angular/until-destroyed.mixin";
import {ProjectResource} from "core-app/modules/hal/resources/project-resource";
import {PrincipalLike} from "core-app/modules/invite-user-modal/invite-user-modal.types";
import {PrincipalLike} from "core-app/modules/principal/principal-types";
import {PrincipalType} from '../invite-user.component';
@Component({

@ -11,10 +11,10 @@ import {
Validators,
} from '@angular/forms';
import {I18nService} from "core-app/modules/common/i18n/i18n.service";
import {PrincipalType} from '../invite-user.component';
import {HalResource} from "core-app/modules/hal/resources/hal-resource";
import {PrincipalLike} from "core-app/modules/invite-user-modal/invite-user-modal.types";
import {PrincipalLike} from "core-app/modules/principal/principal-types";
import {ProjectResource} from "core-app/modules/hal/resources/project-resource";
import {PrincipalType} from '../invite-user.component';
@Component({
selector: 'op-ium-principal',

@ -10,7 +10,7 @@ import {mapTo, switchMap} from "rxjs/operators";
import {I18nService} from "core-app/modules/common/i18n/i18n.service";
import {APIV3Service} from "core-app/modules/apiv3/api-v3.service";
import {RoleResource} from "core-app/modules/hal/resources/role-resource";
import {PrincipalLike} from "core-app/modules/invite-user-modal/invite-user-modal.types";
import {PrincipalLike} from "core-app/modules/principal/principal-types";
import {HalResource} from "core-app/modules/hal/resources/hal-resource";
import {ProjectResource} from 'core-app/modules/hal/resources/project-resource';
import {PrincipalType} from '../invite-user.component';

@ -3,16 +3,21 @@ import {PathHelperService} from "core-app/modules/common/path-helper/path-helper
import {ColorsService} from "core-app/modules/common/colors/colors.service";
import {APIV3Service} from "core-app/modules/apiv3/api-v3.service";
import {PrincipalLike} from "./principal-types";
import {PrincipalHelper} from "./principal-helper";
import PrincipalType = PrincipalHelper.PrincipalType;
export interface PrincipalLike {
id:string;
name:string;
href:string;
}
export type AvatarSize = 'default'|'medium'|'mini';
export interface AvatarOptions {
classes:string;
hide: boolean,
size: AvatarSize,
classes?: string,
}
export interface NameOptions {
hide: boolean,
link: boolean,
}
@Injectable({ providedIn: 'root' })
@ -26,73 +31,79 @@ export class PrincipalRendererService {
renderMultiple(container:HTMLElement,
users:PrincipalLike[],
renderName:boolean = true,
name: NameOptions = { hide: false, link: false },
avatar:AvatarOptions = { hide: false, size: 'default' },
multiLine:boolean = false) {
const span = document.createElement('span');
const list = document.createElement('span');
for (let i = 0; i < users.length; i++) {
const avatar = document.createElement('span');
const userElement = document.createElement('span');
if (multiLine) {
avatar.classList.add('user-avatar--multi-line');
userElement.classList.add('user-avatar--multi-line');
}
this.render(avatar, users[i], renderName);
this.render(userElement, users[i], name, avatar);
list.appendChild(userElement);
if (!multiLine && i < users.length - 1) {
const sep = document.createElement('span');
sep.textContent = ', ';
avatar.appendChild(sep);
list.appendChild(sep);
}
span.appendChild(avatar);
}
container.appendChild(span);
container.appendChild(list);
}
render(container:HTMLElement,
principal:PrincipalLike,
name:boolean = true,
avatar:false|AvatarOptions = { classes: 'avatar-medium' }):void {
render(
container:HTMLElement,
principal:PrincipalLike,
name: NameOptions = { hide: false, link: true },
avatar:AvatarOptions = { hide: false, size: 'default' },
):void {
const type = PrincipalHelper.typeFromHref(principal.href || '')!;
container.classList.add('op-principal');
const type = PrincipalHelper.typeFromHref(principal.href)!;
if (avatar) {
if (!avatar.hide) {
const el = this.renderAvatar(principal, avatar, type);
container.appendChild(el);
}
if (name) {
const el = this.renderName(principal, type);
if (!name.hide) {
const el = this.renderName(principal, type, name.link);
container.appendChild(el);
}
}
private renderAvatar(principal:PrincipalLike, avatar:AvatarOptions, type:PrincipalType) {
private renderAvatar(principal:PrincipalLike, options:AvatarOptions, type:PrincipalType) {
const userInitials = this.getInitials(principal.name);
const colorCode = this.colors.toHsl(principal.name);
console.log('rendering avatar', options, userInitials);
let fallback = document.createElement('div');
fallback.className = avatar.classes;
fallback.classList.add('avatar-default');
fallback.className = options.classes || '';
fallback.classList.add('avatar');
fallback.classList.add(`avatar-${options.size}`);
fallback.classList.add('avatar--fallback');
fallback.textContent = userInitials;
fallback.style.background = colorCode;
// Image avatars are only supported for users
if (type === 'user') {
this.renderUserAvatar(principal, fallback, avatar);
this.renderUserAvatar(principal, fallback, options);
}
return fallback;
}
private renderUserAvatar(principal:PrincipalLike, fallback:HTMLElement, avatar:AvatarOptions) {
private renderUserAvatar(principal:PrincipalLike, fallback:HTMLElement, options:AvatarOptions) {
const image = new Image();
image.className = avatar.classes;
image.classList.add('avatar--fallback');
image.src = this.apiV3Service.users.id(principal.id).avatar.toString();
image.className = options.classes || '';
image.classList.add('avatar');
image.classList.add(`avatar-${options.size}`);
image.src = this.apiV3Service.users.id(principal.id || '').avatar.toString();
image.title = principal.name;
image.alt = principal.name;
image.onload = function () {
@ -101,23 +112,29 @@ export class PrincipalRendererService {
};
}
private renderName(principal:PrincipalLike, type:PrincipalType) {
const link = document.createElement('a');
link.textContent = principal.name;
link.href = this.principalURL(principal, type);
link.target = '_blank';
private renderName(principal:PrincipalLike, type:PrincipalType, asLink = true) {
if (asLink) {
const link = document.createElement('a');
link.textContent = principal.name;
link.href = this.principalURL(principal, type);
link.target = '_blank';
return link;
return link;
}
const span = document.createElement('span');
span.textContent = principal.name;
return span;
}
private principalURL(principal:PrincipalLike, type:PrincipalType) {
switch (type) {
case 'group':
return this.pathHelper.groupPath(principal.id);
return this.pathHelper.groupPath(principal.id || '');
case 'placeholder_user':
return this.pathHelper.placeholderUserPath(principal.id);
return this.pathHelper.placeholderUserPath(principal.id || '');
case 'user':
return this.pathHelper.userPath(principal.id);
return this.pathHelper.userPath(principal.id || '');
}
}

@ -0,0 +1,5 @@
import {UserResource} from "core-app/modules/hal/resources/user-resource";
import {PlaceholderUserResource} from "core-app/modules/hal/resources/placeholder-user-resource";
import {GroupResource} from "core-app/modules/hal/resources/group-resource";
export type PrincipalLike = UserResource|PlaceholderUserResource|GroupResource|{ id?:string, name:string, href?:string };

@ -32,21 +32,29 @@ import {PathHelperService} from 'core-app/modules/common/path-helper/path-helper
import {TimezoneService} from 'core-components/datetime/timezone.service';
import {APIV3Service} from "core-app/modules/apiv3/api-v3.service";
import {
PrincipalRendererService,
AvatarSize,
} from "./principal-renderer.service";
import {PrincipalLike} from "./principal-types";
import {PrincipalHelper} from "./principal-helper";
import PrincipalPluralType = PrincipalHelper.PrincipalPluralType;
import {PrincipalLike, PrincipalRendererService} from "./principal-renderer.service";
export const principalSelector = 'op-principal';
@Component({
template: '',
selector: 'op-principal',
selector: principalSelector,
host: {'class': 'op-principal'}
})
export class OpPrincipalComponent implements OnInit {
/** If coming from angular, pass a principal resource if available */
@Input() principal:PrincipalLike;
@Input() renderAvatar:boolean = true;
@Input() renderName:boolean = true;
@Input() avatarClasses:string = '';
@Input('hide-avatar') hideAvatar:boolean = false;
@Input('hide-name') hideName:boolean = false;
@Input() link:boolean = true;
@Input() size:AvatarSize = 'default';
@Input('avatar-classes') avatarClasses:string = '';
public constructor(readonly elementRef:ElementRef,
readonly PathHelper:PathHelperService,
@ -62,29 +70,39 @@ export class OpPrincipalComponent implements OnInit {
if (!this.principal) {
this.principal = this.principalFromDataset(element);
this.renderAvatar = element.dataset.renderAvatar === 'true';
this.renderName = element.dataset.renderName === 'true';
this.hideAvatar = element.dataset.hideAvatar === 'true';
this.hideName = element.dataset.hideName === 'true';
this.link = element.dataset.link === 'true';
this.size = element.dataset.size ?? 'default';
this.avatarClasses = element.dataset.avatarClasses ?? '';
}
this.principalRenderer.render(
element,
this.principal,
this.renderName,
this.renderAvatar ? { classes: this.avatarClasses } : false
{
hide: this.hideName,
link: this.link,
},
{
hide: this.hideAvatar,
size: this.size,
classes: this.avatarClasses,
},
);
}
private principalFromDataset(element:HTMLElement) {
const id = element.dataset.principalId!;
const name = element.dataset.principalName!;
const type = element.dataset.principalType;
const plural = type + 's' as PrincipalPluralType;
const href = this.apiV3Service[plural].id(id).toString();
return {
id: id,
name: element.dataset.principalName!,
href: href
id,
name,
href,
}
}
}

@ -1,4 +1,4 @@
<svg width="149" height="140" viewBox="0 0 149 140" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M45 0C44.5312 0 44.0625 0.234375 43.5938 0.234375C18.9844 0.9375 0 22.2656 0 47.1094V108.75C0 111.094 1.64062 112.5 3.75 112.5C4.6875 112.5 5.625 112.266 6.32812 111.562L12.1875 107.109C12.8906 106.641 13.5938 106.406 14.2969 106.406C15.4688 106.406 16.4062 106.875 17.1094 107.578L27.1875 119.062C27.8906 119.766 28.8281 120.234 30 120.234C30.9375 120.234 31.875 119.766 32.5781 119.062L42.1875 108.281C42.8906 107.344 43.8281 107.109 45 107.109C45.9375 107.109 46.875 107.344 47.5781 108.281L57.1875 119.062C57.8906 119.766 58.8281 120.234 59.7656 120.234C60.9375 120.234 61.875 119.766 62.5781 119.062L72.6562 107.578C73.125 106.875 74.5312 106.406 75.4688 106.406C76.1719 106.406 77.1094 106.875 77.5781 107.109L83.4375 111.562C84.1406 112.266 85.0781 112.5 86.0156 112.5C88.125 112.5 90 111.094 90 108.75V45C90 20.1562 69.8438 0 45 0ZM78.75 95.625C77.5781 95.3906 76.4062 95.1562 75.4688 95.1562C71.0156 95.1562 67.0312 97.0312 64.2188 100.312L60 105L56.0156 100.781C53.2031 97.5 49.2188 95.8594 45 95.8594C40.5469 95.8594 36.5625 97.5 33.75 100.781L30 105L25.5469 100.312C23.2031 97.5 18.0469 95.1562 14.2969 95.1562C13.3594 95.1562 12.1875 95.3906 11.0156 95.625V47.1094C11.0156 27.8906 25.7812 11.9531 43.8281 11.4844L45 11.25C63.5156 11.25 78.75 26.4844 78.75 45V95.625ZM30 37.5C25.7812 37.5 22.5 41.0156 22.5 45C22.5 49.2188 25.7812 52.5 30 52.5C33.9844 52.5 37.5 49.2188 37.5 45C37.5 41.0156 33.9844 37.5 30 37.5ZM60 37.5C55.7812 37.5 52.5 41.0156 52.5 45C52.5 49.2188 55.7812 52.5 60 52.5C63.9844 52.5 67.5 49.2188 67.5 45C67.5 41.0156 63.9844 37.5 60 37.5Z" fill="#A0AEC0"/>
<path d="M136.973 53.9006L140.643 50.0673L136.943 46.2625L126.153 35.1657L122.179 31.0792L118.237 35.1963L62.238 93.6809L45.7632 76.4748L41.8211 72.3578L37.8474 76.4443L27.0569 87.5411L23.3571 91.3459L27.0274 95.1791L58.2654 127.804L62.238 131.953L66.2105 127.804L136.973 53.9006Z" fill="#1A67A3" stroke="white" stroke-width="11"/>
<path d="M45 0C44.5312 0 44.0625 0.234375 43.5938 0.234375C18.9844 0.9375 0 22.2656 0 47.1094V108.75C0 111.094 1.64062 112.5 3.75 112.5C4.6875 112.5 5.625 112.266 6.32812 111.562L12.1875 107.109C12.8906 106.641 13.5938 106.406 14.2969 106.406C15.4688 106.406 16.4062 106.875 17.1094 107.578L27.1875 119.062C27.8906 119.766 28.8281 120.234 30 120.234C30.9375 120.234 31.875 119.766 32.5781 119.062L42.1875 108.281C42.8906 107.344 43.8281 107.109 45 107.109C45.9375 107.109 46.875 107.344 47.5781 108.281L57.1875 119.062C57.8906 119.766 58.8281 120.234 59.7656 120.234C60.9375 120.234 61.875 119.766 62.5781 119.062L72.6562 107.578C73.125 106.875 74.5312 106.406 75.4688 106.406C76.1719 106.406 77.1094 106.875 77.5781 107.109L83.4375 111.562C84.1406 112.266 85.0781 112.5 86.0156 112.5C88.125 112.5 90 111.094 90 108.75V45C90 20.1562 69.8438 0 45 0ZM78.75 95.625C77.5781 95.3906 76.4062 95.1562 75.4688 95.1562C71.0156 95.1562 67.0312 97.0312 64.2188 100.312L60 105L56.0156 100.781C53.2031 97.5 49.2188 95.8594 45 95.8594C40.5469 95.8594 36.5625 97.5 33.75 100.781L30 105L25.5469 100.312C23.2031 97.5 18.0469 95.1562 14.2969 95.1562C13.3594 95.1562 12.1875 95.3906 11.0156 95.625V47.1094C11.0156 27.8906 25.7812 11.9531 43.8281 11.4844L45 11.25C63.5156 11.25 78.75 26.4844 78.75 45V95.625ZM30 37.5C25.7812 37.5 22.5 41.0156 22.5 45C22.5 49.2188 25.7812 52.5 30 52.5C33.9844 52.5 37.5 49.2188 37.5 45C37.5 41.0156 33.9844 37.5 30 37.5ZM60 37.5C55.7812 37.5 52.5 41.0156 52.5 45C52.5 49.2188 55.7812 52.5 60 52.5C63.9844 52.5 67.5 49.2188 67.5 45C67.5 41.0156 63.9844 37.5 60 37.5Z" fill="#A0AEC0"/>
<path d="M136.973 53.9006L140.643 50.0673L136.943 46.2625L126.153 35.1657L122.179 31.0792L118.237 35.1963L62.238 93.6809L45.7632 76.4748L41.8211 72.3578L37.8474 76.4443L27.0569 87.5411L23.3571 91.3459L27.0274 95.1791L58.2654 127.804L62.238 131.953L66.2105 127.804L136.973 53.9006Z" fill="#1A67A3" stroke="white" stroke-width="11"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

@ -30,44 +30,40 @@
border-radius: var(--user-avatar-border-radius)
width: var(--user-avatar-width)
height: var(--user-avatar-height)
color: white
display: inline-block
text-align: center
vertical-align: middle
cursor: inherit
user-select: none
// Default size
line-height: 34px
font-size: 16px
background-color: var(--user-avatar-default-bg-color)
.avatar-mini
border-radius: var(--user-avatar-mini-border-radius)
width: var(--user-avatar-mini-width)
height: var(--user-avatar-mini-height)
margin-right: 10px -5px
&.avatar-default
line-height: var(--user-avatar-mini-height)
font-size: 10px
line-height: var(--user-avatar-mini-height)
font-size: 10px
.avatar-medium
border-radius: var(--user-avatar-medium-border-radius)
width: var(--user-avatar-medium-width)
height: var(--user-avatar-medium-height)
&.avatar-default
line-height: var(--user-avatar-medium-height)
font-size: 13px
vertical-align: inherit
.avatar-default
display: inline-block
line-height: 34px
font-size: 16px
text-align: center
vertical-align: middle
background-color: var(--user-avatar-default-bg-color)
color: white
cursor: inherit
user-select: none
line-height: var(--user-avatar-medium-height)
font-size: 13px
vertical-align: inherit
h1, h2, h3, h4
user-avatar
op-principal
vertical-align: middle
margin-right: 5px
tr user-avatar
tr op-principal
margin-right: 5px
.user-link

@ -27,7 +27,7 @@
//++
.work-package--watchers
user-avatar
op-principal
margin-right: 5px
.work-package--watchers.-read-only

@ -86,13 +86,20 @@ AvatarHelper.class_eval do
opts = options.merge(gravatar: default_gravatar_options)
tag_options = merge_image_options(user, opts)
tag_options[:class] << ' avatar--gravatar-image avatar--fallback'
tag_options[:class] = [
tag_options[:class],
'avatar--gravatar-image',
'avatar--fallback'
].reject(&:empty?).join(' ')
content_tag 'user-avatar',
content_tag 'op-principal',
'',
'data-class-list': tag_options[:class],
'data-user-id': user.id,
'data-user-name': user.name
'data-avatar-classes': tag_options[:class],
'data-size': tag_options[:size],
'data-principal-id': user.id,
'data-principal-name': user.name,
'data-principal-type': 'user',
'data-hide-name': 'true'
end
def build_gravatar_image_url(user, options = {})
@ -115,15 +122,21 @@ AvatarHelper.class_eval do
def local_avatar_image_tag(user, options = {})
tag_options = merge_image_options(user, options)
content_tag 'user-avatar',
content_tag 'op-principal',
'',
'data-class-list': tag_options[:class],
'data-user-id': user.id,
'data-user-name': user.name
'data-avatar-classes': tag_options[:class],
'data-principal-id': user.id,
'data-principal-name': user.name,
'data-principal-type': 'user',
'data-size': tag_options[:size],
'data-hide-name': 'true'
end
def merge_image_options(user, options)
default_options = { class: 'avatar' }
default_options = {
class: '',
size: 'default'
}
default_options[:title] = h(user.name) if user.respond_to?(:name)
options.reverse_merge(default_options)
@ -149,13 +162,15 @@ AvatarHelper.class_eval do
def build_default_avatar_image_tag(user, options = {})
tag_options = merge_image_options(user, options)
tag_options[:class] << ' avatar-default'
content_tag 'user-avatar',
content_tag 'op-principal',
'',
'data-class-list': tag_options[:class],
'data-user-name': user.name,
'data-use-fallback': 'true'
'data-avatar-classes': tag_options[:class],
'data-size': tag_options[:size],
'data-principal-name': user.name,
'data-principal-id': user.id,
'data-principal-type': 'user',
'data-hide-name': 'true'
end
prepend InstanceMethods

@ -23,11 +23,14 @@ describe AvatarHelper, type: :helper, with_settings: { protocol: 'http' } do
end
def local_expected_user_avatar_tag(user)
tag_options = { 'data-user-id': user.id,
'data-user-name': user.name,
'data-class-list': 'avatar' }
content_tag 'user-avatar', '', tag_options
tag_options = { 'data-principal-id': user.id,
'data-principal-name': user.name,
'data-principal-type': 'user',
'data-hide-name': 'true',
'data-avatar-classes': '',
'data-size': 'default' }
content_tag 'op-principal', '', tag_options
end
def local_expected_url(user)
@ -35,25 +38,31 @@ describe AvatarHelper, type: :helper, with_settings: { protocol: 'http' } do
end
def default_expected_user_avatar_tag(user)
tag_options = { 'data-use-fallback': "true",
'data-user-name': user.name,
'data-class-list': 'avatar avatar-default' }
content_tag 'user-avatar', '', tag_options
tag_options = { 'data-hide-name': 'true',
'data-principal-id': user.id,
'data-principal-name': user.name,
'data-principal-type': 'user',
'data-avatar-classes': '',
'data-size': 'default'}
content_tag 'op-principal', '', tag_options
end
def gravatar_expected_user_avatar_tag(_digest, _options = {})
tag_options = { 'data-user-id': user.id,
'data-user-name': user.name,
'data-class-list': 'avatar avatar--gravatar-image avatar--fallback' }
content_tag 'user-avatar', '', tag_options
tag_options = { 'data-principal-id': user.id,
'data-principal-name': user.name,
'data-hide-name': 'true',
'data-principal-type': 'user',
'data-size': 'default',
'data-avatar-classes': 'avatar--gravatar-image avatar--fallback' }
content_tag 'op-principal', '', tag_options
end
def gravatar_expected_image_tag(digest, options = {})
tag_options = options.reverse_merge(title: user.name,
alt: 'Gravatar',
class: 'avatar avatar--gravatar-image avatar--fallback').delete_if { |key, value| value.nil? || key == :ssl }
class: 'avatar--gravatar-image avatar--fallback').delete_if { |key, value| value.nil? || key == :ssl }
image_tag gravatar_expected_url(digest, options), tag_options
end

@ -149,7 +149,7 @@ describe 'Assignee action board',
board_page.expect_card 'Bob Self', 'Some Task', present: false
# Expect to have changed the avatar
expect(page).to have_selector('.wp-card--assignee .avatar-default', text: 'FB', wait: 10)
expect(page).to have_selector('.wp-card--assignee .avatar-mini', text: 'FB', wait: 10)
work_package.reload
expect(work_package.assigned_to).to eq(foobar_user)
@ -161,7 +161,7 @@ describe 'Assignee action board',
board_page.expect_card 'Bob Self', 'Some Task', present: false
# Expect to have changed the avatar
expect(page).to have_selector('.wp-card--assignee .avatar-default', text: 'GG', wait: 10)
expect(page).to have_selector('.wp-card--assignee .avatar-mini', text: 'GG', wait: 10)
work_package.reload
expect(work_package.assigned_to).to eq(group)

Loading…
Cancel
Save