Merge pull request #1077 from theseyi/avatar-list

adds avatar interfaces ,components and styling. replaces scrolling avatar view with modal dialog
This commit is contained in:
Seyi Adebajo 2018-04-05 00:40:44 -07:00 committed by GitHub
commit 2912d38fec
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 677 additions and 21 deletions

View File

@ -0,0 +1,33 @@
import Component from '@ember/component';
import ComputedProperty, { alias } from '@ember/object/computed';
import { computed, get } from '@ember/object';
import { IAvatar } from 'wherehows-web/typings/app/avatars';
export default class AvatarImage extends Component {
tagName = 'img';
classNames = ['avatar'];
attributeBindings = ['src', 'alt:name'];
/**
* The avatar object to render
* @type {IAvatar}
*/
avatar: IAvatar;
/**
* Returns the image url for the avatar
* @type {ComputedProperty<string>}
*/
src: ComputedProperty<string | void> = computed('avatar.imageUrl', function(this: AvatarImage): string | void {
//@ts-ignore dot notation property access
return get(this, 'avatar.imageUrl');
});
/**
* Aliases the name property on the related avatar
* @type {ComputedProperty<IAvatar['name']>}
*/
name: ComputedProperty<IAvatar['name']> = alias('avatar.name');
}

View File

@ -0,0 +1,20 @@
import Component from '@ember/component';
import { IAvatar } from 'wherehows-web/typings/app/avatars';
export default class extends Component {
tagName: 'span';
classNames = ['avatar-metadata'];
/**
* Reference to avatar containing metadata
* @type {IAvatar}
*/
avatar: IAvatar;
/**
* Slack team ID
* @type {string}
*/
team = 'T06BYN8F7';
}

View File

@ -0,0 +1,28 @@
import Component from '@ember/component';
import { get } from '@ember/object';
import { action } from 'ember-decorators/object';
enum Key {
Escape = 27
}
export default class extends Component {
containerClassNames = ['avatars-detail-modal'];
/**
* External action to close detail interface
*/
onClose: () => void;
/**
* Handles key up event on interface
* @param {KeyboardEvent} { key, which }
*/
@action
onKeyUp({ key, which }: KeyboardEvent) {
// if escape key, close modal
if (which === Key.Escape || key === 'Escape') {
get(this, 'onClose')();
}
}
}

View File

@ -0,0 +1,63 @@
import Component from '@ember/component';
import { IAvatar } from 'wherehows-web/typings/app/avatars';
import { set, get, computed } from '@ember/object';
import ComputedProperty from '@ember/object/computed';
import { action } from 'ember-decorators/object';
import { singularize, pluralize } from 'ember-inflector';
export default class extends Component {
tagName = 'span';
classNames = ['avatar-rollup'];
constructor() {
super(...arguments);
this.avatars || (this.avatars = []);
this.avatarType || (this.avatarType = 'entity');
}
/**
* References the full list of avatars
* @type {Array<IAvatar>}
*/
avatars: Array<IAvatar>;
/**
* Flag indicating if the avatars detail view should be rendered
* @type {boolean}
*/
isShowingAvatars = false;
/**
* The type of avatars being shown
* @type {string}
*/
avatarType: string;
/**
* Returns the text to be shown in the avatar detail page header
* @type {ComputedProperty<string>}
*/
header: ComputedProperty<string> = computed('avatars.length', function(): string {
const count = get(this, 'avatars').length;
const suffix = get(this, 'avatarType');
return `${count} ${count > 1 ? pluralize(suffix) : singularize(suffix)}`;
});
/**
* Handles the component click event
*/
click() {
set(this, 'isShowingAvatars', true);
}
/**
* Updates the flag indicating if the view should be rendered
*/
@action
dismissAvatars() {
set(this, 'isShowingAvatars', false);
}
}

View File

@ -0,0 +1,61 @@
import Component from '@ember/component';
import { get, getProperties, computed } from '@ember/object';
import ComputedProperty from '@ember/object/computed';
import { IAvatar } from 'wherehows-web/typings/app/avatars';
/**
* Specifies the default maximum number of images to render before the more button
* @type {number}
*/
const defaultMavAvatarLength = 6;
export default class StackedAvatarsList extends Component {
classNames = ['avatar-container'];
constructor() {
super(...arguments);
this.avatars || (this.avatars = []);
}
/**
* The list of avatar objects to render
* @type {Array<IAvatar>}
*/
avatars: Array<IAvatar>;
/**
* Calculates the max number of avatars to render
* @type {ComputedProperty<number>}
* @memberof StackedAvatarsList
*/
maxAvatarLength: ComputedProperty<number> = computed('avatars.length', function(this: StackedAvatarsList): number {
const { length } = get(this, 'avatars');
return length ? Math.min(length, defaultMavAvatarLength) : defaultMavAvatarLength;
});
/**
* Build the list of avatars to render based on the max number
* @type {ComputedProperty<StackedAvatarsList['avatars']>}
* @memberof StackedAvatarsList
*/
maxAvatars: ComputedProperty<StackedAvatarsList['avatars']> = computed('maxAvatarLength', function(
this: StackedAvatarsList
): StackedAvatarsList['avatars'] {
const { avatars, maxAvatarLength } = getProperties(this, ['avatars', 'maxAvatarLength']);
return avatars.slice(0, maxAvatarLength);
});
/**
* Determines the list of avatars that have not been rendered after the max has been ascertained
* @type {ComputedProperty<StackedAvatarsList['avatars']>}
* @memberof StackedAvatarsList
*/
rollupAvatars: ComputedProperty<StackedAvatarsList['avatars']> = computed('maxAvatars', function(
this: StackedAvatarsList
): StackedAvatarsList['avatars'] {
const { avatars, maxAvatarLength } = getProperties(this, ['avatars', 'maxAvatarLength']);
return avatars.slice(maxAvatarLength);
});
}

View File

@ -1,6 +1,6 @@
import Component from '@ember/component';
import { get, set } from '@ember/object';
import { gte } from '@ember/object/computed';
import ComputedProperty, { gte } from '@ember/object/computed';
import { TaskInstance, TaskProperty } from 'ember-concurrency';
import { action } from 'ember-decorators/object';
import { IAccessControlAccessTypeOption } from 'wherehows-web/typings/api/datasets/aclaccess';
@ -59,7 +59,7 @@ export default class DatasetAclAccess extends Component {
* @type {ComputedProperty<boolean>}
* @memberof DatasetAclAccess
*/
hasValidExpiration = gte('selectedDate', minSelectableExpirationDate.getTime());
hasValidExpiration: ComputedProperty<boolean> = gte('selectedDate', minSelectableExpirationDate.getTime());
/**
* External task to remove the logged in user from the related dataset's acl

View File

@ -14,9 +14,11 @@ import {
updateOwner,
minRequiredConfirmedOwners,
validConfirmedOwners,
isRequiredMinOwnersNotConfirmed
isRequiredMinOwnersNotConfirmed,
isConfirmedOwner,
isSystemGeneratedOwner
} from 'wherehows-web/constants/datasets/owner';
import { OwnerIdType, OwnerSource, OwnerType } from 'wherehows-web/utils/api/datasets/owners';
import { OwnerSource, OwnerType } from 'wherehows-web/utils/api/datasets/owners';
import Notifications, { NotificationEvent } from 'wherehows-web/services/notifications';
/**
@ -101,7 +103,7 @@ export default class DatasetAuthors extends Component {
* @type {ComputedProperty<Array<IOwner>>}
* @memberof DatasetAuthors
*/
confirmedOwners: ComputedProperty<Array<IOwner>> = filter('owners', ({ source }) => source === OwnerSource.Ui);
confirmedOwners: ComputedProperty<Array<IOwner>> = filter('owners', isConfirmedOwner);
/**
* Intersection of confirmed owners and suggested owners
@ -129,9 +131,7 @@ export default class DatasetAuthors extends Component {
* @type {ComputedProperty<Array<IOwner>>}
* @memberof DatasetAuthors
*/
systemGeneratedOwners: ComputedProperty<Array<IOwner>> = filter('owners', function({ source, idType }: IOwner) {
return source !== OwnerSource.Ui && idType === OwnerIdType.User;
});
systemGeneratedOwners: ComputedProperty<Array<IOwner>> = filter('owners', isSystemGeneratedOwner);
/**
* Invokes the external action as a dropping task

View File

@ -0,0 +1,59 @@
import Component from '@ember/component';
import { get, set, computed } from '@ember/object';
import ComputedProperty from '@ember/object/computed';
import { IAvatar } from 'wherehows-web/typings/app/avatars';
import { IOwner, IOwnerResponse } from 'wherehows-web/typings/api/datasets/owners';
import { task } from 'ember-concurrency';
import { readDatasetOwnersByUrn } from 'wherehows-web/utils/api/datasets/owners';
import { arrayMap } from 'wherehows-web/utils/array';
import { getAvatarProps } from 'wherehows-web/constants/avatars/avatars';
import { confirmedOwners } from 'wherehows-web/constants/datasets/owner';
export default class DatasetOwnerListContainer extends Component {
constructor() {
super(...arguments);
this.owners || (this.owners = []);
}
/**
* Urn for the related dataset
* @type {string}
* @memberof DatasetOwnerListContainer
*/
urn: string;
/**
* The owners for the dataset
* @type {Array<IOwner>}
* @memberof DatasetOwnerListContainer
*/
owners: Array<IOwner>;
/**
* Lists the avatar objects based off the dataset owners
* @type {ComputedProperty<Array<IAvatar>>}
* @memberof DatasetOwnerListContainer
*/
avatars: ComputedProperty<Array<IAvatar>> = computed('owners', function(): Array<IAvatar> {
return arrayMap(getAvatarProps)(get(this, 'owners'));
});
didInsertElement() {
get(this, 'getOwnersTask').perform();
}
didUpdateAttrs() {
get(this, 'getOwnersTask').perform();
}
/**
* Reads the owners for this dataset
* @type {Task<Promise<Array<IOwnerResponse>>, () => TaskInstance<Promise<IOwnerResponse>>>}
*/
getOwnersTask = task(function*(this: DatasetOwnerListContainer): IterableIterator<Promise<IOwnerResponse>> {
const { owners = [] }: IOwnerResponse = yield readDatasetOwnersByUrn(get(this, 'urn'));
set(this, 'owners', confirmedOwners(owners));
}).restartable();
}

View File

@ -0,0 +1,33 @@
import { IAvatar } from 'wherehows-web/typings/app/avatars';
import { avatar } from 'wherehows-web/constants/application';
import { pick } from 'lodash';
/**
* Defines a partial interface for an object that can be narrowed into an avatar
*/
type PartAvatar = Partial<IAvatar>;
const { url: avatarUrl } = avatar;
/**
* Default image url if an avatar url cannot be determined
* @type {string}
*/
const defaultAvatarImage = '/assets/assets/images/default_avatar.png';
/**
* Takes a PartAvatar object and build an object confirming to an IAvatar
* @param {PartAvatar} object
* @return {IAvatar}
*/
const getAvatarProps = (object: PartAvatar): IAvatar => {
const props = pick(object, ['email', 'userName', 'name']);
const imageUrl = props.userName ? avatarUrl.replace('[username]', props.userName) : defaultAvatarImage;
return {
imageUrl,
...props
};
};
export { getAvatarProps };

View File

@ -129,6 +129,27 @@ const isValidConfirmedOwner = ({ confirmedBy, type, idType, isActive }: IOwner):
*/
const validConfirmedOwners = arrayFilter(isValidConfirmedOwner);
/**
* Checks if an owner has been confirmed by a user, i.e. OwnerSource.Ui
* @param {IOwner} { source }
* @returns {boolean}
*/
const isConfirmedOwner = ({ source }: IOwner): boolean => source === OwnerSource.Ui;
/**
* Takes a list of owners and returns those that are confirmed
* @type {(array: Array<IOwner>) => Array<IOwner>}
*/
const confirmedOwners = arrayFilter(isConfirmedOwner);
/**
* Checks that an owner is a system generated owner
* @param {IOwner} { source, idType }
* @returns {boolean}
*/
const isSystemGeneratedOwner = ({ source, idType }: IOwner): boolean =>
source !== OwnerSource.Ui && idType === OwnerIdType.User;
/**
* Checks that the required minimum number of confirmed users is met with the type Owner and idType User
* @param {Array<IOwner>} owners the list of owners to check
@ -145,5 +166,8 @@ export {
isRequiredMinOwnersNotConfirmed,
ownerAlreadyExists,
updateOwner,
confirmOwner
confirmOwner,
isConfirmedOwner,
confirmedOwners,
isSystemGeneratedOwner
};

View File

@ -14,6 +14,8 @@ body {
padding-top: $nav-min-height;
padding-bottom: $nav-min-height;
background-color: set-color(white, base);
overflow-y: scroll;
overflow-x: hidden;
}
/**
@ -22,7 +24,9 @@ body {
* making all elements inheriting from the root box-sizing value
* See: https://css-tricks.com/inheriting-box-sizing-probably-slightly-better-best-practice/
*/
*, *::before, *::after {
*,
*::before,
*::after {
box-sizing: inherit;
}

View File

@ -4,7 +4,6 @@
body {
color: $text-color;
font: normal 300 150% / 1.4 $text-font-stack;
overflow-y: scroll;
}
h1,

View File

@ -1,6 +1,6 @@
@import 'avatar';
@import 'navbar';
@import 'hero';
@import 'avatar/all';
@import 'dataset-author/all';
@import 'dataset-compliance/all';
@import 'browse-nav/all';

View File

@ -1,5 +0,0 @@
$avatar-size: 18px;
.user-avatar {
@include round-image($avatar-size);
}

View File

@ -0,0 +1,2 @@
@import 'avatar';
@import 'avatars-detail';

View File

@ -0,0 +1,116 @@
$avatar-size: 18px;
.user-avatar {
@include round-image($avatar-size);
}
.avatar-container {
display: flex;
justify-content: flex-end;
&:hover {
.avatar-item--stacked {
margin-left: item-spacing(7) / 2;
transition-duration: 167ms;
transition-timing-function: cubic-bezier(0, 0, 0.2, 1);
transition-delay: 0s;
}
}
&__list {
list-style-type: none;
}
}
.avatar-item {
display: inline;
&--stacked {
transition-duration: 167ms;
transition-timing-function: cubic-bezier(0.4, 0, 1, 1);
transition-delay: 0s;
position: relative;
&:nth-of-type(1n + 2) {
margin-left: -(item-spacing(7) / 2);
}
&__meta {
position: absolute;
right: 0;
opacity: 0;
z-index: z(dropdown);
}
&:hover {
.avatar-item--stacked__meta {
transition-duration: 167ms;
transition-timing-function: cubic-bezier(0, 0, 0.2, 1);
transition-delay: 0s;
opacity: 1;
}
}
}
}
.avatar {
@include round-image(item-spacing(7));
box-sizing: border-box;
background-clip: content-box;
border: 2px solid transparent;
&--stacked {
border: 2px solid white;
transition-duration: 167ms;
transition-timing-function: cubic-bezier(0.4, 0, 1, 1);
transition-delay: 0s;
&:nth-of-type(1n + 2) {
margin-left: -(item-spacing(7) / 2);
}
}
}
.avatar-rollup {
@include round-image(item-spacing(7));
box-sizing: border-box;
background-clip: content-box;
border: 1px solid get-color(black, 0.5);
font-weight: 400;
color: get-color(black, 0.5);
font-size: 15px;
display: inline-block;
background: white;
text-align: center;
z-index: 2;
line-height: item-spacing(7);
cursor: pointer;
}
.avatar-meta-action {
background-color: transparent;
border: 0;
border-radius: 2px;
box-sizing: border-box;
color: get-color(black, 0.7);
cursor: pointer;
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding: item-spacing(0 2);
display: block;
text-decoration: none;
vertical-align: middle;
&:hover {
background-color: get-color(black, 0.05);
transition-duration: 167ms;
transition-property: background-color, box-shadow, color;
transition-timing-function: cubic-bezier(0, 0, 0.2, 1);
}
}
.avatar-metadata {
max-width: item-spacing(7) * 2;
}

View File

@ -0,0 +1,84 @@
.avatars-detail-modal {
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.1), 0 12px 18px 1px rgba(0, 0, 0, 0.2);
position: relative;
border-radius: 2px;
background-color: white;
max-height: calc(100vh - 64px);
min-height: item-spacing(7);
width: 520px;
display: flex;
flex-direction: column;
&__header,
&__content {
display: flex;
}
&__header {
padding: item-spacing(4 4);
justify-content: space-between;
align-items: center;
border-bottom: 1px solid rgba(0, 0, 0, 0.15);
top: 0;
left: 0;
width: 100%;
min-height: 48px;
}
&__close {
font-weight: fw(normal, 2);
font-size: 48px;
background-color: transparent;
text-align: center;
text-decoration: none;
vertical-align: middle;
cursor: pointer;
box-sizing: border-box;
color: get-color(black, 0.7);
border: 0;
}
}
.avatars-detail-modal-overlay {
position: fixed;
top: 0;
left: 0;
z-index: z('modal');
display: flex;
height: 100vh;
width: 100vw;
align-items: center;
justify-content: center;
background-color: rgba(41, 39, 36, 0.75);
}
.avatar-detail-container {
margin: 0;
padding: item-spacing(0 0 0 3);
width: 100%;
overflow-y: auto;
overflow-x: hidden;
}
.avatar-detail {
display: flex;
align-items: center;
&__entity {
flex-grow: 1;
}
&__meta {
display: flex;
flex-direction: column;
flex-grow: 10;
border-bottom: 1px solid rgba(0, 0, 0, 0.15);
max-width: 100%;
}
&:last-of-type {
.avatar-detail__meta {
border: 0;
}
}
}

View File

@ -26,7 +26,9 @@ $secondary-border-color: set-color(white, base);
text-overflow: ellipsis,
white-space: nowrap,
display: block
)) @include restyle-define(grain, (
));
@include restyle-define(grain, (
restyle-var(primary-border-color): $primary-border-color,
restyle-var(secondary-border-color): $secondary-border-color,
float: left,
@ -59,7 +61,9 @@ $secondary-border-color: set-color(white, base);
margin-top: -50px,
left: 100%,
)
)) .nacho-breadcrumbs {
));
.nacho-breadcrumbs {
@include restyle(breadcrumbs);
&__crumb {

View File

@ -0,0 +1 @@
{{yield}}

View File

@ -0,0 +1,6 @@
{{yield (hash
userName=avatar.userName
email=avatar.email
name=avatar.name
team=team
)}}

View File

@ -0,0 +1,16 @@
{{#modal-dialog
overlayClass="avatars-detail-modal-overlay"
containerClassNames=containerClassNames
onClose=onClose
}}
<header class="avatars-detail-modal__header">
<h2>
{{header}}
</h2>
<button class="avatars-detail-modal__close" onclick={{action onClose}}>&times;</button>
</header>
<section class="avatars-detail-modal__content">
{{yield}}
</section>
{{/modal-dialog}}

View File

@ -0,0 +1,37 @@
{{yield}}
{{#if isShowingAvatars}}
{{#avatars/avatars-detail
header=header
onClose=(action "dismissAvatars")}}
<ul class="avatar-detail-container">
{{#each avatars as |avatar|}}
<li class="avatar-detail">
<div class="avatar-detail__entity">
<img src="{{avatar.imageUrl}}" alt="{{avatar.name}}" class="avatar">
</div>
{{#avatars/avatar-metadata class="avatar-detail__meta" avatar=avatar as |aviMeta|}}
<h4>{{aviMeta.name}}</h4>
{{#if aviMeta.email}}
<a href="mailto:{{aviMeta.email}}" class="avatar-meta-action" target="_blank">
<strong><i class="fa fa-at"></i></strong> {{aviMeta.email}}
</a>
{{/if}}
{{#if aviMeta.userName}}
<a href="slack://user?team={{aviMeta.team}}&id={{aviMeta.userName}}" class="avatar-meta-action">
<i class="fa fa-slack"></i> {{aviMeta.userName}}
</a>
{{/if}}
{{/avatars/avatar-metadata}}
</li>
{{/each}}
</ul>
{{/avatars/avatars-detail}}
{{/if}}

View File

@ -0,0 +1,44 @@
{{#if hasBlock}}
{{yield
(hash
image=(component "avatars/avatar-image")
meta=(component "avatars/avatar-metadata")
maxAvatars=maxAvatars
rollupAvatars=rollupAvatars
)
}}
{{else}}
<ul class="avatar-container__list">
{{#each maxAvatars as |avatar|}}
<li class="avatar-item avatar-item--stacked">
{{avatars/avatar-image avatar=avatar class="avatar--stacked"}}
{{#avatars/avatar-metadata class="avatar-item--stacked__meta" avatar=avatar as |aviMeta|}}
{{#if aviMeta.email}}
<a href="mailto:{{aviMeta.email}}" class="avatar-meta-action" target="_blank">
<strong><i class="fa fa-at"></i></strong> {{aviMeta.email}}
</a>
{{/if}}
{{#if aviMeta.userName}}
<a href="slack://user?team={{aviMeta.team}}&id={{aviMeta.userName}}" class="avatar-meta-action">
<i class="fa fa-slack"></i> {{aviMeta.userName}}
</a>
{{/if}}
{{/avatars/avatar-metadata}}
</li>
{{/each}}
</ul>
{{#if rollupAvatars}}
{{#avatars/rollup-avatars avatars=avatars avatarType=avatarType}}
{{rollupAvatars.length}}+
{{/avatars/rollup-avatars}}
{{/if}}
{{/if}}

View File

@ -84,7 +84,7 @@
{{moment-format calendar.center 'MMMM YYYY'}}
<p>
{{if hasValidExpiration (concat "Expires in " (moment-from-now calendar.selected)) ""}}
{{if hasValidExpiration (concat "Expires in " (moment-from-now calendar.selected))}}
</p>
</div>

View File

@ -0,0 +1 @@
{{avatars/stacked-avatars-list avatars=avatars avatarType="owner"}}

View File

@ -46,7 +46,7 @@
</div>
</div>
{{dataset-owner-list owners=owners datasetName=model.nativeName}}
{{datasets/containers/dataset-owner-list urn=encodedUrn}}
</div>
{{#ivy-tabs selection=tabSelected as |tabs|}}

View File

@ -0,0 +1,13 @@
/**
* Describes the interface for an avatar object
* @interface IAvatar
*/
interface IAvatar {
imageUrl: string;
email?: null | string;
// Handle for the avatar
userName?: string;
name?: string;
}
export { IAvatar };

View File

@ -33,6 +33,12 @@ declare module 'ember-simple-auth/services/session' {
}
}
declare module 'ember-inflector' {
const singularize: (arg: string) => string;
const pluralize: (arg: string) => string;
export { singularize, pluralize };
}
declare module 'wherehows-web/utils/datasets/compliance-policy' {
export const isPolicyExpectedShape: (policy: object) => boolean;
}

View File

@ -60,6 +60,7 @@
"ember-export-application-global": "^2.0.0",
"ember-fetch": "^3.4.4",
"ember-font-awesome": "^4.0.0-rc.2",
"ember-inflector": "^2.2.0",
"ember-load-initializers": "^1.0.0",
"ember-math-helpers": "^2.4.0",
"ember-metrics": "^0.12.1",

View File

@ -3431,6 +3431,12 @@ ember-inflector@^2.0.0:
dependencies:
ember-cli-babel "^6.0.0"
ember-inflector@^2.2.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/ember-inflector/-/ember-inflector-2.2.0.tgz#edd273dfd1a29be27f14b195e2f0ed70e812d9e0"
dependencies:
ember-cli-babel "^6.0.0"
ember-load-initializers@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/ember-load-initializers/-/ember-load-initializers-1.0.0.tgz#4919eaf06f6dfeca7e134633d8c05a6c9921e6e7"