mirror of
https://github.com/datahub-project/datahub.git
synced 2025-09-27 18:14:54 +00:00
Merge pull request #1410 from theseyi/email-avatars-and-reversible
refactors implementation and design of stacked-avatars-list componen…
This commit is contained in:
commit
b0037ccbc2
@ -1,7 +1,8 @@
|
|||||||
import Component from '@ember/component';
|
import Component from '@ember/component';
|
||||||
import { get, getProperties, computed } from '@ember/object';
|
import { IAvatar, IAvatarDropDownAction } from 'wherehows-web/typings/app/avatars';
|
||||||
import ComputedProperty from '@ember/object/computed';
|
import { action, computed } from '@ember-decorators/object';
|
||||||
import { IAvatar } from 'wherehows-web/typings/app/avatars';
|
import { IDropDownOption } from 'wherehows-web/typings/app/dataset-compliance';
|
||||||
|
import { classNames } from '@ember-decorators/component';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Specifies the default maximum number of images to render before the more button
|
* Specifies the default maximum number of images to render before the more button
|
||||||
@ -9,8 +10,13 @@ import { IAvatar } from 'wherehows-web/typings/app/avatars';
|
|||||||
*/
|
*/
|
||||||
const defaultMavAvatarLength = 6;
|
const defaultMavAvatarLength = 6;
|
||||||
|
|
||||||
|
@classNames('avatar-container')
|
||||||
export default class StackedAvatarsList extends Component {
|
export default class StackedAvatarsList extends Component {
|
||||||
classNames = ['avatar-container'];
|
/**
|
||||||
|
* The list of avatar objects to render
|
||||||
|
* @type {Array<IAvatar>}
|
||||||
|
*/
|
||||||
|
avatars: Array<IAvatar>;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super(...arguments);
|
super(...arguments);
|
||||||
@ -18,44 +24,53 @@ export default class StackedAvatarsList extends Component {
|
|||||||
this.avatars || (this.avatars = []);
|
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
|
* Calculates the max number of avatars to render
|
||||||
* @type {ComputedProperty<number>}
|
* @type {ComputedProperty<number>}
|
||||||
* @memberof StackedAvatarsList
|
* @memberof StackedAvatarsList
|
||||||
*/
|
*/
|
||||||
maxAvatarLength: ComputedProperty<number> = computed('avatars.length', function(this: StackedAvatarsList): number {
|
@computed('avatars.length')
|
||||||
const { length } = get(this, 'avatars');
|
get maxAvatarLength(): number {
|
||||||
|
const {
|
||||||
|
avatars: { length }
|
||||||
|
} = this;
|
||||||
return length ? Math.min(length, defaultMavAvatarLength) : defaultMavAvatarLength;
|
return length ? Math.min(length, defaultMavAvatarLength) : defaultMavAvatarLength;
|
||||||
});
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the list of avatars to render based on the max number
|
* Build the list of avatars to render based on the max number
|
||||||
* @type {ComputedProperty<StackedAvatarsList['avatars']>}
|
* @type {ComputedProperty<StackedAvatarsList['avatars']>}
|
||||||
* @memberof StackedAvatarsList
|
* @memberof StackedAvatarsList
|
||||||
*/
|
*/
|
||||||
maxAvatars: ComputedProperty<StackedAvatarsList['avatars']> = computed('maxAvatarLength', function(
|
@computed('maxAvatarLength')
|
||||||
this: StackedAvatarsList
|
get maxAvatars(): StackedAvatarsList['avatars'] {
|
||||||
): StackedAvatarsList['avatars'] {
|
const { avatars, maxAvatarLength } = this;
|
||||||
const { avatars, maxAvatarLength } = getProperties(this, ['avatars', 'maxAvatarLength']);
|
|
||||||
|
|
||||||
return avatars.slice(0, maxAvatarLength);
|
return avatars.slice(0, maxAvatarLength);
|
||||||
});
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determines the list of avatars that have not been rendered after the max has been ascertained
|
* Determines the list of avatars that have not been rendered after the max has been ascertained
|
||||||
* @type {ComputedProperty<StackedAvatarsList['avatars']>}
|
* @type {ComputedProperty<StackedAvatarsList['avatars']>}
|
||||||
* @memberof StackedAvatarsList
|
* @memberof StackedAvatarsList
|
||||||
*/
|
*/
|
||||||
rollupAvatars: ComputedProperty<StackedAvatarsList['avatars']> = computed('maxAvatars', function(
|
@computed('maxAvatars')
|
||||||
this: StackedAvatarsList
|
get rollupAvatars(): StackedAvatarsList['avatars'] {
|
||||||
): StackedAvatarsList['avatars'] {
|
const { avatars, maxAvatarLength } = this;
|
||||||
const { avatars, maxAvatarLength } = getProperties(this, ['avatars', 'maxAvatarLength']);
|
|
||||||
return avatars.slice(maxAvatarLength);
|
return avatars.slice(maxAvatarLength);
|
||||||
});
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler to invoke IAvatarDropDownAction instance when the drop down option is selected
|
||||||
|
* @param {IAvatar} avatar the avatar item selected from the list
|
||||||
|
* @param {(IDropDownOption<IAvatarDropDownAction> | void)} selectedOption drop down option selected
|
||||||
|
* @memberof StackedAvatarsList
|
||||||
|
*/
|
||||||
|
@action
|
||||||
|
onAvatarOptionSelected(avatar: IAvatar, selectedOption: IDropDownOption<IAvatarDropDownAction> | void): void {
|
||||||
|
const { value } = selectedOption || { value: (a: IAvatar) => a };
|
||||||
|
|
||||||
|
value(avatar);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,18 +1,20 @@
|
|||||||
import Component from '@ember/component';
|
import Component from '@ember/component';
|
||||||
import { set } from '@ember/object';
|
import { set } from '@ember/object';
|
||||||
import { task } from 'ember-concurrency';
|
import { classNames } from '@ember-decorators/component';
|
||||||
import { assert } from '@ember/debug';
|
|
||||||
import { computed } from '@ember-decorators/object';
|
import { computed } from '@ember-decorators/object';
|
||||||
|
import { assert } from '@ember/debug';
|
||||||
|
import { task } from 'ember-concurrency';
|
||||||
import { readDatasetOwnersByUrn } from 'wherehows-web/utils/api/datasets/owners';
|
import { readDatasetOwnersByUrn } from 'wherehows-web/utils/api/datasets/owners';
|
||||||
import { arrayMap } from 'wherehows-web/utils/array';
|
import { arrayMap, arrayPipe } from 'wherehows-web/utils/array';
|
||||||
import { IAvatar } from 'wherehows-web/typings/app/avatars';
|
import { IAvatar } from 'wherehows-web/typings/app/avatars';
|
||||||
import { IOwner, IOwnerResponse } from 'wherehows-web/typings/api/datasets/owners';
|
import { IOwner, IOwnerResponse } from 'wherehows-web/typings/api/datasets/owners';
|
||||||
import { getAvatarProps } from 'wherehows-web/constants/avatars/avatars';
|
import { getAvatarProps } from 'wherehows-web/constants/avatars/avatars';
|
||||||
import { confirmedOwners } from 'wherehows-web/constants/datasets/owner';
|
import { confirmedOwners, avatarWithDropDownOption } from 'wherehows-web/constants/datasets/owner';
|
||||||
import { containerDataSource } from 'wherehows-web/utils/components/containers/data-source';
|
import { containerDataSource } from 'wherehows-web/utils/components/containers/data-source';
|
||||||
import { isLiUrn } from 'wherehows-web/utils/validators/urn';
|
import { decodeUrn, isLiUrn } from 'wherehows-web/utils/validators/urn';
|
||||||
import { IAppConfig } from 'wherehows-web/typings/api/configurator/configurator';
|
import { IAppConfig } from 'wherehows-web/typings/api/configurator/configurator';
|
||||||
|
|
||||||
|
@classNames('dataset-owner-list')
|
||||||
@containerDataSource('getOwnersTask')
|
@containerDataSource('getOwnersTask')
|
||||||
export default class DatasetOwnerListContainer extends Component {
|
export default class DatasetOwnerListContainer extends Component {
|
||||||
constructor() {
|
constructor() {
|
||||||
@ -57,7 +59,12 @@ export default class DatasetOwnerListContainer extends Component {
|
|||||||
@computed('owners')
|
@computed('owners')
|
||||||
get avatars(): Array<IAvatar> {
|
get avatars(): Array<IAvatar> {
|
||||||
const { avatarEntityProps, owners } = this;
|
const { avatarEntityProps, owners } = this;
|
||||||
return arrayMap(getAvatarProps(avatarEntityProps))(owners);
|
const [getAvatarProperties, augmentAvatarsWithDropDownOption] = [
|
||||||
|
arrayMap(getAvatarProps(avatarEntityProps)),
|
||||||
|
arrayMap(avatarWithDropDownOption)
|
||||||
|
];
|
||||||
|
|
||||||
|
return arrayPipe(getAvatarProperties, augmentAvatarsWithDropDownOption)(owners);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -67,7 +74,7 @@ export default class DatasetOwnerListContainer extends Component {
|
|||||||
getOwnersTask = task(function*(this: DatasetOwnerListContainer): IterableIterator<Promise<IOwnerResponse>> {
|
getOwnersTask = task(function*(this: DatasetOwnerListContainer): IterableIterator<Promise<IOwnerResponse>> {
|
||||||
const { urn } = this;
|
const { urn } = this;
|
||||||
|
|
||||||
if (isLiUrn(urn)) {
|
if (isLiUrn(decodeUrn(urn))) {
|
||||||
const { owners = [] }: IOwnerResponse = yield readDatasetOwnersByUrn(urn);
|
const { owners = [] }: IOwnerResponse = yield readDatasetOwnersByUrn(urn);
|
||||||
|
|
||||||
set(this, 'owners', confirmedOwners(owners));
|
set(this, 'owners', confirmedOwners(owners));
|
||||||
|
@ -2,6 +2,8 @@ import { set } from '@ember/object';
|
|||||||
import { IOwner } from 'wherehows-web/typings/api/datasets/owners';
|
import { IOwner } from 'wherehows-web/typings/api/datasets/owners';
|
||||||
import { OwnerIdType, OwnerSource, OwnerType, OwnerUrnNamespace } from 'wherehows-web/utils/api/datasets/owners';
|
import { OwnerIdType, OwnerSource, OwnerType, OwnerUrnNamespace } from 'wherehows-web/utils/api/datasets/owners';
|
||||||
import { arrayFilter, isListUnique } from 'wherehows-web/utils/array';
|
import { arrayFilter, isListUnique } from 'wherehows-web/utils/array';
|
||||||
|
import { IAvatar } from 'wherehows-web/typings/app/avatars';
|
||||||
|
import { buildMailToUrl } from 'wherehows-web/utils/helpers/email';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initial user name for candidate owners
|
* Initial user name for candidate owners
|
||||||
@ -150,6 +152,27 @@ const confirmedOwners = arrayFilter(isConfirmedOwner);
|
|||||||
const isRequiredMinOwnersNotConfirmed = (owners: Array<IOwner> = []): boolean =>
|
const isRequiredMinOwnersNotConfirmed = (owners: Array<IOwner> = []): boolean =>
|
||||||
validConfirmedOwners(owners).length < minRequiredConfirmedOwners;
|
validConfirmedOwners(owners).length < minRequiredConfirmedOwners;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Augments an owner instance of IAvatar requiring property avatarOptions to exist on the returned instance
|
||||||
|
* @param {IAvatar} avatar the avatar object to augment
|
||||||
|
* @returns {(IAvatar & Required<Pick<IAvatar, 'avatarOptions'>>)}
|
||||||
|
*/
|
||||||
|
const avatarWithDropDownOption = (avatar: IAvatar): IAvatar & Required<Pick<IAvatar, 'avatarOptions'>> => {
|
||||||
|
const email = avatar.email || '';
|
||||||
|
|
||||||
|
return {
|
||||||
|
...avatar,
|
||||||
|
avatarOptions: [
|
||||||
|
{
|
||||||
|
// if the owner avatar does not have an email then a null value is returned with no action performed
|
||||||
|
value: ({ email }: IAvatar): Window | null =>
|
||||||
|
email ? window.open(buildMailToUrl({ to: email || '' }), '_blank') : null,
|
||||||
|
label: email
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export {
|
export {
|
||||||
defaultOwnerProps,
|
defaultOwnerProps,
|
||||||
defaultOwnerUserName,
|
defaultOwnerUserName,
|
||||||
@ -160,5 +183,6 @@ export {
|
|||||||
updateOwner,
|
updateOwner,
|
||||||
confirmOwner,
|
confirmOwner,
|
||||||
isConfirmedOwner,
|
isConfirmedOwner,
|
||||||
confirmedOwners
|
confirmedOwners,
|
||||||
|
avatarWithDropDownOption
|
||||||
};
|
};
|
||||||
|
@ -29,6 +29,7 @@
|
|||||||
@import 'dataset-health/all';
|
@import 'dataset-health/all';
|
||||||
@import 'search/all';
|
@import 'search/all';
|
||||||
@import 'hotkey/all';
|
@import 'hotkey/all';
|
||||||
|
@import 'dataset-owner/all';
|
||||||
|
|
||||||
@import 'nacho/nacho-button';
|
@import 'nacho/nacho-button';
|
||||||
@import 'nacho/nacho-global-search';
|
@import 'nacho/nacho-global-search';
|
||||||
|
@ -5,11 +5,27 @@
|
|||||||
|
|
||||||
.avatar-container {
|
.avatar-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-start;
|
||||||
|
padding: item-spacing(3 0);
|
||||||
|
|
||||||
|
/// --rtl modifier allows avatars to be animated from right-to-left
|
||||||
|
/// avatar-container element should ideally be positioned against the right edge of it's container
|
||||||
|
&--rtl {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
.avatar-item--stacked {
|
.avatar-item--stacked {
|
||||||
margin-left: item-spacing(7) / 2;
|
margin-right: item-spacing(7) / 2;
|
||||||
transition-duration: 167ms;
|
transition-duration: 167ms;
|
||||||
transition-timing-function: cubic-bezier(0, 0, 0.2, 1);
|
transition-timing-function: cubic-bezier(0, 0, 0.2, 1);
|
||||||
transition-delay: 0s;
|
transition-delay: 0s;
|
||||||
@ -18,6 +34,8 @@
|
|||||||
|
|
||||||
&__list {
|
&__list {
|
||||||
list-style-type: none;
|
list-style-type: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -52,6 +70,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__trigger {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.avatar {
|
.avatar {
|
||||||
@ -71,6 +93,10 @@
|
|||||||
margin-left: -(item-spacing(7) / 2);
|
margin-left: -(item-spacing(7) / 2);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&--focused {
|
||||||
|
border-color: get-color(blue5);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.avatar-rollup {
|
.avatar-rollup {
|
||||||
|
@ -0,0 +1 @@
|
|||||||
|
@import 'owners';
|
@ -0,0 +1,5 @@
|
|||||||
|
.dataset-owner-list {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
margin: item-spacing(1 0);
|
||||||
|
}
|
@ -22,12 +22,4 @@
|
|||||||
right: item-spacing(5);
|
right: item-spacing(5);
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dataset-owner-list {
|
|
||||||
margin-left: item-spacing(4);
|
|
||||||
|
|
||||||
.avatar-container {
|
|
||||||
justify-content: flex-start;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -14,30 +14,28 @@
|
|||||||
<ul class="avatar-container__list">
|
<ul class="avatar-container__list">
|
||||||
{{#each maxAvatars as |avatar|}}
|
{{#each maxAvatars as |avatar|}}
|
||||||
<li class="avatar-item avatar-item--stacked">
|
<li class="avatar-item avatar-item--stacked">
|
||||||
|
{{#nacho/dropdown/hover-dropdown
|
||||||
|
dropDownItems=avatar.avatarOptions
|
||||||
|
selectedDropDown=null
|
||||||
|
wrappedTriggerComponent=true
|
||||||
|
renderInPlace=true
|
||||||
|
onSelect=(action "onAvatarOptionSelected" avatar) as |dd|}}
|
||||||
|
|
||||||
{{avatars/avatar-image avatar=avatar class="avatar--stacked"}}
|
{{#dd.trigger class="avatar-item__trigger"}}
|
||||||
|
{{avatars/avatar-image avatar=avatar
|
||||||
|
class=(if dd.isExpanded "avatar--stacked avatar--focused" "avatar--stacked")}}
|
||||||
|
{{/dd.trigger}}
|
||||||
|
|
||||||
{{#avatars/avatar-metadata class="avatar-item--stacked__meta" avatar=avatar as |aviMeta|}}
|
{{dd.content}}
|
||||||
{{#if aviMeta.email}}
|
{{/nacho/dropdown/hover-dropdown}}
|
||||||
<a href="mailto:{{aviMeta.email}}" class="avatar-meta-action" target="_blank" rel="noopener">
|
|
||||||
<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>
|
</li>
|
||||||
{{/each}}
|
{{/each}}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
{{#if rollupAvatars}}
|
{{#if rollupAvatars}}
|
||||||
{{#avatars/rollup-avatars avatars=avatars avatarType=avatarType}}
|
{{#avatars/rollup-avatars avatars=avatars avatarType=@avatarType}}
|
||||||
{{rollupAvatars.length}}+
|
+{{rollupAvatars.length}}
|
||||||
{{/avatars/rollup-avatars}}
|
{{/avatars/rollup-avatars}}
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
|
||||||
|
@ -1,20 +1,28 @@
|
|||||||
{{#basic-dropdown as |bd|}}
|
{{#basic-dropdown renderInPlace=@renderInPlace as |bd|}}
|
||||||
{{#bd.trigger class="nacho-drop-down__trigger"
|
{{#if (not-eq @wrappedTriggerComponent true)}}
|
||||||
onMouseDown=(action "prevent")
|
{{#bd.trigger class="nacho-drop-down__trigger"
|
||||||
onMouseEnter=(action "showDropDown")
|
onMouseDown=(action "prevent")
|
||||||
onMouseLeave=(action "hideDropDown")}}
|
onMouseEnter=(action "showDropDown")
|
||||||
<strong class="nacho-drop-down__active">
|
onMouseLeave=(action "hideDropDown")}}
|
||||||
{{selectedDropDown.label}}
|
<strong class="nacho-drop-down__active">
|
||||||
</strong>
|
{{selectedDropDown.label}}
|
||||||
|
</strong>
|
||||||
|
|
||||||
{{#if selectedDropDown}}
|
{{#if selectedDropDown}}
|
||||||
<span class="nacho-drop-down__active__toggle">
|
<span class="nacho-drop-down__active__toggle">
|
||||||
{{fa-icon (if isExpanded "caret-up" "caret-down")}}
|
{{fa-icon (if isExpanded "caret-up" "caret-down")}}
|
||||||
</span>
|
</span>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
{{/bd.trigger}}
|
{{/bd.trigger}}
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
{{yield (hash
|
{{yield (hash
|
||||||
|
trigger=(component bd.trigger
|
||||||
|
onMouseDown=(action "prevent")
|
||||||
|
onMouseEnter=(action "showDropDown")
|
||||||
|
onMouseLeave=(action "hideDropDown")
|
||||||
|
)
|
||||||
|
isExpanded=isExpanded
|
||||||
content=(component 'nacho/dropdown/dropdown-content'
|
content=(component 'nacho/dropdown/dropdown-content'
|
||||||
baseComponent=bd.content
|
baseComponent=bd.content
|
||||||
onMouseEnter=(action "showDropDown")
|
onMouseEnter=(action "showDropDown")
|
||||||
|
@ -56,16 +56,14 @@
|
|||||||
</div>
|
</div>
|
||||||
{{/datasets/containers/dataset-fabrics}}
|
{{/datasets/containers/dataset-fabrics}}
|
||||||
|
|
||||||
{{datasets/containers/dataset-owner-list
|
|
||||||
urn=encodedUrn
|
|
||||||
avatarEntityProps=avatarEntityProps
|
|
||||||
class="dataset-owner-list"}}
|
|
||||||
|
|
||||||
{{#link-to "datasets.dataset.health" encodedUrn}}
|
{{#link-to "datasets.dataset.health" encodedUrn}}
|
||||||
{{datasets/containers/health-score-gauge urn=encodedUrn wikiLinks=wikiLinks}}
|
{{datasets/containers/health-score-gauge urn=encodedUrn wikiLinks=wikiLinks}}
|
||||||
{{/link-to}}
|
{{/link-to}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{datasets/containers/dataset-owner-list
|
||||||
|
urn=encodedUrn
|
||||||
|
avatarEntityProps=avatarEntityProps}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
14
wherehows-web/app/typings/app/avatars.d.ts
vendored
14
wherehows-web/app/typings/app/avatars.d.ts
vendored
@ -1,3 +1,13 @@
|
|||||||
|
import { IDropDownOption } from 'wherehows-web/typings/app/dataset-compliance';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines the interface for functions that are supplied as options to IAvatar.avatarOptions
|
||||||
|
* @interface IAvatarDropDownAction
|
||||||
|
*/
|
||||||
|
interface IAvatarDropDownAction {
|
||||||
|
(avatar: IAvatar): any;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Describes the interface for an avatar object
|
* Describes the interface for an avatar object
|
||||||
* @interface IAvatar
|
* @interface IAvatar
|
||||||
@ -11,6 +21,8 @@ interface IAvatar {
|
|||||||
// Handle for the avatar
|
// Handle for the avatar
|
||||||
userName?: string;
|
userName?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
|
// Selection options for an avatar with dropdown
|
||||||
|
avatarOptions?: Array<IDropDownOption<IAvatarDropDownAction>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export { IAvatar };
|
export { IAvatar, IAvatarDropDownAction };
|
||||||
|
14
wherehows-web/app/typings/app/core.d.ts
vendored
Normal file
14
wherehows-web/app/typings/app/core.d.ts
vendored
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* Defines a generic type for a type T that could also be undefined
|
||||||
|
* @template T type which maybe is generic over
|
||||||
|
*/
|
||||||
|
type Maybe<T> = T | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines a generic type that recasts a type T as a union of U and an intersection of T and U
|
||||||
|
* @template T the type to be recast
|
||||||
|
* @template U the result of the type recast
|
||||||
|
*/
|
||||||
|
type Recast<T, U> = T & U | U;
|
||||||
|
|
||||||
|
export { Maybe, Recast };
|
18
wherehows-web/app/typings/app/helpers/email.d.ts
vendored
Normal file
18
wherehows-web/app/typings/app/helpers/email.d.ts
vendored
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { Maybe } from 'wherehows-web/typings/app/core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Union string literal of safe email headers and the body field
|
||||||
|
*/
|
||||||
|
type MailerHeaders = 'to' | 'cc' | 'bcc' | 'subject' | 'body';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alias for a string of list of string email header values
|
||||||
|
* @alias
|
||||||
|
*/
|
||||||
|
export type MailerHeaderValue = Maybe<string>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An optional record of email headers to it's value - MailerHeaderValue
|
||||||
|
* @alias
|
||||||
|
*/
|
||||||
|
export type IMailHeaderRecord = Partial<Record<MailerHeaders, MailerHeaderValue>>;
|
@ -1,18 +1,24 @@
|
|||||||
import { encode, decode } from 'wherehows-web/utils/encode-decode-uri-component-with-space';
|
import { encode, decode } from 'wherehows-web/utils/encode-decode-uri-component-with-space';
|
||||||
import { isBlank } from '@ember/utils';
|
import { isBlank } from '@ember/utils';
|
||||||
|
import { isObject } from 'wherehows-web/utils/object';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Construct a url by appending a query pair (?key=value | &key=value) to a base url and
|
* Construct a url by appending a query pair (?key=value | &key=value) to a base url and
|
||||||
* encoding the query value in the pair
|
* encoding the query value in the pair
|
||||||
* @param {String} baseUrl the base or original url that will be appended with a query string
|
* @param baseUrl the base or original url that will be appended with a query string
|
||||||
* @param {String} queryParam
|
* @param queryParamOrMap a map of query keys to values or a single query param key
|
||||||
* @param {String} queryValue
|
* @param queryValue if a queryParam is supplied, then a queryValue can be expected
|
||||||
|
* @param useEncoding flag indicating if the query values should be encoded
|
||||||
* @returns {string}
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
function buildUrl(): string;
|
function buildUrl(baseUrl: string, queryParamOrMap?: {}, useEncoding?: boolean): string;
|
||||||
function buildUrl(baseUrl: string, mapParams: Record<string, any>): string;
|
function buildUrl(baseUrl: string, queryParamOrMap?: string, queryValue?: string, useEncoding?: boolean): string;
|
||||||
function buildUrl(baseUrl: string, queryKey: string, queryValue: string): string;
|
function buildUrl(
|
||||||
function buildUrl(baseUrl?: string, queryParamOrMap?: string | Record<string, string>, queryValue?: string): string {
|
baseUrl: string,
|
||||||
|
queryParamOrMap: string | Record<string, any> = {},
|
||||||
|
queryValue?: string | boolean,
|
||||||
|
useEncoding: boolean = true
|
||||||
|
): string {
|
||||||
if (!baseUrl) {
|
if (!baseUrl) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
@ -21,16 +27,24 @@ function buildUrl(baseUrl?: string, queryParamOrMap?: string | Record<string, st
|
|||||||
return baseUrl;
|
return baseUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
let paramMap: { [x: string]: string };
|
let paramMap: Record<string, any> = {};
|
||||||
|
|
||||||
|
// queryParamOrMap is a string then, reify paramMap object with supplied value
|
||||||
if (typeof queryParamOrMap === 'string') {
|
if (typeof queryParamOrMap === 'string') {
|
||||||
paramMap = {
|
paramMap = {
|
||||||
[queryParamOrMap]: queryValue || ''
|
[queryParamOrMap]: queryValue
|
||||||
};
|
};
|
||||||
} else {
|
|
||||||
paramMap = queryParamOrMap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return Object.keys(paramMap).reduce((url, paramKey) => {
|
if (isObject(queryParamOrMap)) {
|
||||||
|
paramMap = queryParamOrMap;
|
||||||
|
|
||||||
|
if (typeof queryValue === 'boolean') {
|
||||||
|
useEncoding = queryValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.keys(paramMap).reduce((url: string, paramKey: string): string => {
|
||||||
// If the query string already contains the initial question mark append
|
// If the query string already contains the initial question mark append
|
||||||
// kv-pair with ampersand
|
// kv-pair with ampersand
|
||||||
const separator = String(url).includes('?') ? '&' : '?';
|
const separator = String(url).includes('?') ? '&' : '?';
|
||||||
@ -44,21 +58,23 @@ function buildUrl(baseUrl?: string, queryParamOrMap?: string | Record<string, st
|
|||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Malformed URL will cause decodeURIComponent to throw
|
if (useEncoding) {
|
||||||
// handle and encode queryValue in such instance
|
// Malformed URL will cause decodeURIComponent to throw
|
||||||
try {
|
// handle and encode queryValue in such instance
|
||||||
// Check if queryValue is already encoded,
|
try {
|
||||||
// otherwise encode queryValue before composing url
|
// Check if queryValue is already encoded,
|
||||||
// e.g. if user directly enters query in location bar
|
// otherwise encode queryValue before composing url
|
||||||
if (decode(paramValue) === queryValue) {
|
// e.g. if user directly enters query in location bar
|
||||||
paramValue = encode(paramValue);
|
if (decode(paramValue) === paramValue) {
|
||||||
}
|
paramValue = encode(paramValue);
|
||||||
} catch (err) {
|
}
|
||||||
if (err instanceof URIError) {
|
} catch (err) {
|
||||||
paramValue = encode(paramValue);
|
if (err instanceof URIError) {
|
||||||
}
|
paramValue = encode(paramValue);
|
||||||
|
}
|
||||||
|
|
||||||
throw err;
|
throw err;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${url}${separator}${paramKey}=${paramValue}`;
|
return `${url}${separator}${paramKey}=${paramValue}`;
|
||||||
|
16
wherehows-web/app/utils/helpers/email.ts
Normal file
16
wherehows-web/app/utils/helpers/email.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import buildUrl from 'wherehows-web/utils/build-url';
|
||||||
|
import { IMailHeaderRecord } from 'wherehows-web/typings/app/helpers/email';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs a `mailto:` address with supplied email headers as query parameters
|
||||||
|
* @param {IMailHeaderRecord} [headers={}]
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
const buildMailToUrl = (headers: IMailHeaderRecord = {}): string => {
|
||||||
|
const { to = '', ...otherHeaders } = headers;
|
||||||
|
const mailTo = `mailto:${encodeURIComponent(to)}`;
|
||||||
|
|
||||||
|
return buildUrl(mailTo, otherHeaders);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { buildMailToUrl };
|
@ -0,0 +1,59 @@
|
|||||||
|
const emailAddress = 'nowhere@example.com';
|
||||||
|
const cc = 'nowhere1@example.com';
|
||||||
|
const bcc = 'nowhere-bcc@example.com';
|
||||||
|
const body = 'Message';
|
||||||
|
const subject = 'Email';
|
||||||
|
const base = 'mailto:';
|
||||||
|
const mailToEmail = `${base}${encodeURIComponent(emailAddress)}`;
|
||||||
|
const emailMailToAsserts = [
|
||||||
|
{
|
||||||
|
expected: base,
|
||||||
|
args: void 0,
|
||||||
|
assertMsg: 'it should return a basic mailto: string without an email when no arguments are passed'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expected: base,
|
||||||
|
args: {},
|
||||||
|
assertMsg: 'it should return a basic mailto: string without an email when an empty object is passed'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expected: base,
|
||||||
|
args: { to: '' },
|
||||||
|
assertMsg:
|
||||||
|
'it should return a basic mailto: string without an email when an object with only an empty string in the `to` field is passed'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expected: `${mailToEmail}`,
|
||||||
|
args: { to: emailAddress },
|
||||||
|
assertMsg: 'it should return a mailto: string with an email when an object with only the `to` field is passed'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expected: `${mailToEmail}?cc=${encodeURIComponent(cc)}`,
|
||||||
|
args: { to: emailAddress, cc },
|
||||||
|
assertMsg: 'it should return a mailto: string with an email and a cc query when to and cc are passed in'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expected: `${mailToEmail}?cc=${encodeURIComponent(cc)}&subject=${encodeURIComponent(subject)}`,
|
||||||
|
args: { to: emailAddress, cc, subject },
|
||||||
|
assertMsg:
|
||||||
|
'it should return a mailto: string with an email, subject, and a cc query when to, subject, and cc are passed in'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expected: `${mailToEmail}?cc=${encodeURIComponent(cc)}&subject=${encodeURIComponent(
|
||||||
|
subject
|
||||||
|
)}&bcc=${encodeURIComponent(bcc)}`,
|
||||||
|
args: { to: emailAddress, cc, subject, bcc },
|
||||||
|
assertMsg:
|
||||||
|
'it should return a mailto: string with an email, subject, bcc, and a cc query when to, subject, bcc, and cc are passed in'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expected: `${mailToEmail}?cc=${encodeURIComponent(cc)}&subject=${encodeURIComponent(
|
||||||
|
subject
|
||||||
|
)}&bcc=${encodeURIComponent(bcc)}&body=${encodeURIComponent(body)}`,
|
||||||
|
args: { to: emailAddress, cc, subject, bcc, body },
|
||||||
|
assertMsg:
|
||||||
|
'it should return a mailto: string with an email, subject, bcc, body, and a cc query when to, subject, bcc, body, and cc are passed in'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export { emailMailToAsserts };
|
@ -3,10 +3,42 @@ import { module, test } from 'qunit';
|
|||||||
|
|
||||||
module('Unit | Utility | build url', function() {
|
module('Unit | Utility | build url', function() {
|
||||||
const baseUrl = 'https://www.linkedin.com';
|
const baseUrl = 'https://www.linkedin.com';
|
||||||
|
const unEncodedString = 'string@string';
|
||||||
|
const encodedString = encodeURIComponent(unEncodedString);
|
||||||
|
|
||||||
test('baseUrl', function(assert) {
|
test('baseUrl', function(assert) {
|
||||||
let result = buildUrl();
|
let result = buildUrl(baseUrl);
|
||||||
assert.equal(result, '', 'returns an empty string when no arguments are passed');
|
assert.equal(result, baseUrl, 'it returns the base url when no other arguments are passed');
|
||||||
|
|
||||||
|
result = buildUrl(baseUrl, {});
|
||||||
|
assert.equal(result, baseUrl, 'it returns the base url when an empty object is supplied as query parameter');
|
||||||
|
|
||||||
|
result = buildUrl(baseUrl, '');
|
||||||
|
assert.equal(result, baseUrl, 'it returns the base url when an empty string is supplied as query parameter');
|
||||||
|
|
||||||
|
result = buildUrl(baseUrl, '', true);
|
||||||
|
assert.equal(
|
||||||
|
result,
|
||||||
|
baseUrl,
|
||||||
|
'it returns the base url when the queryParam is an empty string and the useEncoding is set'
|
||||||
|
);
|
||||||
|
|
||||||
|
result = buildUrl(baseUrl, 'query', true);
|
||||||
|
assert.equal(
|
||||||
|
result,
|
||||||
|
`${baseUrl}?query=true`,
|
||||||
|
'it returns the base url with a query key set to true when true is passed as a query value'
|
||||||
|
);
|
||||||
|
|
||||||
|
result = buildUrl(baseUrl, 'query', unEncodedString, true);
|
||||||
|
assert.equal(
|
||||||
|
result,
|
||||||
|
`${baseUrl}?query=${encodedString}`,
|
||||||
|
'it returns the encoded value when the useEncoding flag is true'
|
||||||
|
);
|
||||||
|
|
||||||
|
result = buildUrl(baseUrl, { query: unEncodedString }, true);
|
||||||
|
assert.equal(result, `${baseUrl}?query=${encodedString}`, 'it returns the encoded string on a queryParams object');
|
||||||
|
|
||||||
result = buildUrl(baseUrl, '', '');
|
result = buildUrl(baseUrl, '', '');
|
||||||
assert.equal(result, baseUrl, 'returns the baseUrl when no query parameter is supplied');
|
assert.equal(result, baseUrl, 'returns the baseUrl when no query parameter is supplied');
|
||||||
|
17
wherehows-web/tests/unit/utils/helpers/email-test.ts
Normal file
17
wherehows-web/tests/unit/utils/helpers/email-test.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { buildMailToUrl } from 'wherehows-web/utils/helpers/email';
|
||||||
|
import { module, test } from 'qunit';
|
||||||
|
import { emailMailToAsserts } from 'wherehows-web/tests/helpers/validators/email/email-test-fixture';
|
||||||
|
|
||||||
|
module('Unit | Utility | helpers/email', function(): void {
|
||||||
|
test('buildMailToUrl exists', function(assert) {
|
||||||
|
assert.equal(typeof buildMailToUrl, 'function', 'it is of function type');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildMailToUrl generates expected url values', function(assert): void {
|
||||||
|
assert.expect(emailMailToAsserts.length);
|
||||||
|
|
||||||
|
emailMailToAsserts.forEach(({ expected, args, assertMsg }) => {
|
||||||
|
assert.equal(buildMailToUrl(args), expected, assertMsg);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
Loading…
x
Reference in New Issue
Block a user