diff --git a/wherehows-web/app/components/dataset-author.ts b/wherehows-web/app/components/dataset-author.ts new file mode 100644 index 0000000000..eafb3e4d3a --- /dev/null +++ b/wherehows-web/app/components/dataset-author.ts @@ -0,0 +1,144 @@ +import Component from '@ember/component'; +import ComputedProperty, { equal } from '@ember/object/computed'; +import { getProperties, computed } from '@ember/object'; +import { assert } from '@ember/debug'; + +import { IOwner } from 'wherehows-web/typings/api/datasets/owners'; +import { OwnerSource, OwnerType } from 'wherehows-web/utils/api/datasets/owners'; + +/** + * This component renders a single owner record and also provides functionality for interacting with the component + * in the ui or performing operations on a single owner record + * @export + * @class DatasetAuthor + * @extends {Component} + */ +export default class DatasetAuthor extends Component { + tagName = 'tr'; + + classNames = ['dataset-author-record']; + + classNameBindings = ['isConfirmedSuggestedOwner:dataset-author-record--disabled']; + + /** + * The owner record being rendered + * @type {IOwner} + * @memberof DatasetAuthor + */ + owner: IOwner; + + /** + * List of suggested owners that have been confirmed by a user + * @type {Array} + * @memberof DatasetAuthor + */ + commonOwners: Array; + + /** + * External action to handle owner removal from the confirmed list + * @param {IOwner} owner the owner to be removed + * @memberof DatasetAuthor + */ + removeOwner: (owner: IOwner) => IOwner | void; + + /** + * External action to handle owner addition to the confirmed list + * @param {IOwner} owner the suggested owner to be confirmed + * @return {Array | void} the list of owners or void if unsuccessful + * @memberof DatasetAuthor + */ + confirmSuggestedOwner: (owner: IOwner) => Array | void; + + /** + * External action to handle owner property updates, currently on the confirmed list + * @param {IOwner} owner the owner to update + * @param {OwnerType} type the type of the owner + * @memberof DatasetAuthor + */ + updateOwnerType: (owner: IOwner, type: OwnerType) => void; + + /** + * A list of available owner types retrieved from the api + * @type {Array} + * @memberof DatasetAuthor + */ + ownerTypes: Array; + + /** + * Compares the source attribute on an owner, if it matches the OwnerSource.Ui type + * @type {ComputedProperty} + * @memberof DatasetAuthor + */ + isOwnerMutable: ComputedProperty = equal('owner.source', OwnerSource.Ui); + + /** + * Determines if the owner record is a system suggested owner and if this record is confirmed by a user + * @type {ComputedProperty} + * @memberof DatasetAuthor + */ + isConfirmedSuggestedOwner: ComputedProperty = computed('commonOwners', function(this: DatasetAuthor) { + const { commonOwners, isOwnerMutable, owner: { userName } } = getProperties(this, [ + 'commonOwners', + 'isOwnerMutable', + 'owner' + ]); + + if (!isOwnerMutable) { + return commonOwners.findBy('userName', userName); + } + + return false; + }); + + constructor() { + super(...arguments); + const typeOfRemoveOwner = typeof this.removeOwner; + const typeOfConfirmSuggestedOwner = typeof this.confirmSuggestedOwner; + + // Checks that the expected external actions are provided + assert( + `Expected action removeOwner to be an function (Ember action), got ${typeOfRemoveOwner}`, + typeOfRemoveOwner === 'function' + ); + + assert( + `Expected action confirmOwner to be an function (Ember action), got ${typeOfConfirmSuggestedOwner}`, + typeOfConfirmSuggestedOwner === 'function' + ); + } + + actions = { + /** + * Invokes the external action removeOwner to remove an owner from the confirmed list + * @return {boolean | void | IOwner} + */ + removeOwner: () => { + const { owner, isOwnerMutable, removeOwner } = getProperties(this, ['owner', 'isOwnerMutable', 'removeOwner']); + return isOwnerMutable && removeOwner(owner); + }, + + /** + * Invokes the external action for confirming the suggested owner + * @return {Array | void} + */ + confirmOwner: () => { + const { owner, confirmSuggestedOwner } = getProperties(this, ['owner', 'confirmSuggestedOwner']); + return confirmSuggestedOwner(owner); + }, + + /** + * Updates the type attribute on the owner record + * @param {OwnerType} type value to update the type attribute with + * @return {void} + */ + changeOwnerType: (type: OwnerType) => { + const { owner, isOwnerMutable, updateOwnerType } = getProperties(this, [ + 'owner', + 'isOwnerMutable', + 'updateOwnerType' + ]); + + return isOwnerMutable && updateOwnerType(owner, type); + } + }; +} diff --git a/wherehows-web/app/components/dataset-authors.ts b/wherehows-web/app/components/dataset-authors.ts new file mode 100644 index 0000000000..f51c9b763a --- /dev/null +++ b/wherehows-web/app/components/dataset-authors.ts @@ -0,0 +1,250 @@ +import Component from '@ember/component'; +import { inject } from '@ember/service'; +import ComputedProperty, { or, lt, filter } from '@ember/object/computed'; +import { set, get, computed, getProperties } from '@ember/object'; +import { assert } from '@ember/debug'; +import { isEqual } from 'lodash'; + +import UserLookup from 'wherehows-web/services/user-lookup'; +import CurrentUser from 'wherehows-web/services/current-user'; +import { IOwner } from 'wherehows-web/typings/api/datasets/owners'; +import { + defaultOwnerProps, + defaultOwnerUserName, + minRequiredConfirmedOwners, + ownerAlreadyExists, + userNameEditableClass, + confirmOwner, + updateOwner +} from 'wherehows-web/constants/datasets/owner'; +import { OwnerSource, OwnerIdType, OwnerType } from 'wherehows-web/utils/api/datasets/owners'; +import { ApiStatus } from 'wherehows-web/utils/api'; + +/** + * Defines properties for the component that renders a list of owners and provides functionality for + * interacting with the list items or the list as whole + * @export + * @class DatasetAuthors + * @extends {Component} + */ +export default class DatasetAuthors extends Component { + /** + * Invokes an external save action to persist the list of owners + * @return {Promise<{ status: ApiStatus }>} + * @memberof DatasetAuthors + */ + save: (owners: Array) => Promise<{ status: ApiStatus }>; + + /** + * The list of owners + * @type {Array} + * @memberof DatasetAuthors + */ + owners: Array; + + /** + * Current user service + * @type {ComputedProperty} + * @memberof DatasetAuthors + */ + currentUser: ComputedProperty = inject(); + + /** + * User look up service + * @type {ComputedProperty} + * @memberof DatasetAuthors + */ + userLookup: ComputedProperty = inject(); + + /** + * Reference to the userNamesResolver function to asynchronously match userNames + * @type {UserLookup['userNamesResolver']} + * @memberof DatasetAuthors + */ + userNamesResolver: UserLookup['userNamesResolver']; + + /** + * A list of valid owner type strings returned from the remote api endpoint + * @type {Array} + * @memberof DatasetAuthors + */ + ownerTypes: Array; + + /** + * Computed flag indicating that a set of negative flags is true + * e.g. if the userName is invalid or the required minimum users are not confirmed + * @type {ComputedProperty} + * @memberof DatasetAuthors + */ + ownershipIsInvalid: ComputedProperty = or('userNameInvalid', 'requiredMinNotConfirmed'); + + /** + * Checks that the list of owners does not contain a default user name + * @type {ComputedProperty} + * @memberof DatasetAuthors + */ + userNameInvalid: ComputedProperty = computed('owners.[]', function(this: DatasetAuthors) { + const owners = get(this, 'owners') || []; + + return owners.filter(({ userName }) => userName === defaultOwnerUserName).length > 0; + }); + + /** + * Flag that resolves in the affirmative if the number of confirmed owner is less the minimum required + * @type {ComputedProperty} + * @memberof DatasetAuthors + */ + requiredMinNotConfirmed: ComputedProperty = lt('confirmedOwners.length', minRequiredConfirmedOwners); + + /** + * Lists the owners that have be confirmed view the client ui + * @type {ComputedProperty>} + * @memberof DatasetAuthors + */ + confirmedOwners: ComputedProperty> = filter('owners', function({ source }: IOwner) { + return source === OwnerSource.Ui; + }); + + /** + * Intersection of confirmed owners and suggested owners + * @type {ComputedProperty>} + * @memberof DatasetAuthors + */ + commonOwners: ComputedProperty> = computed( + 'confirmedOwners.@each.userName', + 'systemGeneratedOwners.@each.userName', + function(this: DatasetAuthors) { + const { confirmedOwners = [], systemGeneratedOwners = [] } = getProperties(this, [ + 'confirmedOwners', + 'systemGeneratedOwners' + ]); + + return confirmedOwners.reduce((common, owner) => { + const { userName } = owner; + return systemGeneratedOwners.findBy('userName', userName) ? [...common, owner] : common; + }, []); + } + ); + + /** + * Lists owners that have been gleaned from dataset metadata + * @type {ComputedProperty>} + * @memberof DatasetAuthors + */ + systemGeneratedOwners: ComputedProperty> = filter('owners', function({ source }: IOwner) { + return source !== OwnerSource.Ui; + }); + + constructor() { + super(...arguments); + const typeOfSaveAction = typeof this.save; + + // on instantiation, sets a reference to the userNamesResolver async function + set(this, 'userNamesResolver', get(get(this, 'userLookup'), 'userNamesResolver')); + + assert( + `Expected action save to be an function (Ember action), got ${typeOfSaveAction}`, + typeOfSaveAction === 'function' + ); + } + + actions = { + /** + * Prepares component for updates to the userName attribute + * @param {IOwner} _ unused + * @param {HTMLElement} { currentTarget } + */ + willEditUserName(_: IOwner, { currentTarget }: Event) { + const { classList } = (currentTarget || {}); + if (classList instanceof HTMLElement) { + classList.add(userNameEditableClass); + } + }, + + /** + * Updates the owner instance userName property + * @param {IOwner} currentOwner an instance of an IOwner type to be updates + * @param {string} [userName] optional userName to update to + */ + editUserName: async (currentOwner: IOwner, userName?: string) => { + if (userName) { + const { getPartyEntityWithUserName } = get(this, 'userLookup'); + const partyEntity = await getPartyEntityWithUserName(userName); + + if (partyEntity) { + const { label, displayName, category } = partyEntity; + const isGroup = category === 'group'; + const updatedOwnerProps: IOwner = { + ...currentOwner, + isGroup, + source: OwnerSource.Ui, + userName: label, + name: displayName, + idType: isGroup ? OwnerIdType.Group : OwnerIdType.User + }; + + updateOwner(get(this, 'owners'), currentOwner, updatedOwnerProps); + } + } + }, + + /** + * Adds the component owner record to the list of owners with default props + * @returns {Array | void} + */ + addOwner: () => { + const owners = get(this, 'owners') || []; + const newOwner: IOwner = { ...defaultOwnerProps }; + + if (!ownerAlreadyExists(owners, { userName: newOwner.userName })) { + const { userName } = get(get(this, 'currentUser'), 'currentUser'); + let updatedOwners = [newOwner, ...owners]; + confirmOwner(get(this, 'owners'), newOwner, userName); + + return owners.setObjects(updatedOwners); + } + }, + + /** + * Updates the type attribute for a given owner in the owner list + * @param {IOwner} owner owner to be updates + * @param {OwnerType} type new value to be set on the type attribute + */ + updateOwnerType: (owner: IOwner, type: OwnerType) => { + const owners = get(this, 'owners') || []; + return updateOwner(owners, owner, 'type', type); + }, + + /** + * Adds the owner instance to the list of owners with the source set to ui + * @param {IOwner} owner the owner to add to the list of owner with the source set to OwnerSource.Ui + * @return {Array | void} + */ + confirmSuggestedOwner: (owner: IOwner) => { + const owners = get(this, 'owners') || []; + const suggestedOwner = { ...owner, source: OwnerSource.Ui }; + const hasSuggested = owners.find(owner => isEqual(owner, suggestedOwner)); + + if (!hasSuggested) { + return owners.setObjects([...owners, suggestedOwner]); + } + }, + + /** + * removes an owner instance from the list of owners + * @param {IOwner} owner the owner to be removed + */ + removeOwner: (owner: IOwner) => { + const owners = get(this, 'owners') || []; + return owners.removeObject(owner); + }, + + /** + * Persists the owners list by invoking the external action + */ + saveOwners: () => { + const { save } = this; + save(get(this, 'owners')); + } + }; +} diff --git a/wherehows-web/app/components/dataset-compliance.js b/wherehows-web/app/components/dataset-compliance.js index db4f1e83d1..fc2b3a8bc5 100644 --- a/wherehows-web/app/components/dataset-compliance.js +++ b/wherehows-web/app/components/dataset-compliance.js @@ -340,13 +340,6 @@ export default Component.extend({ label: value ? formatAsCapitalizedStringWithSpaces(value) : '...' })), - /** - * Caches the policy's modification time in milliseconds - */ - policyModificationTimeInEpoch: computed('complianceInfo', function() { - return getWithDefault(this, 'complianceInfo.modifiedTime', 0); - }), - /** * @type {Boolean} cached boolean flag indicating that fields do contain a `kafka type` * tracking header. diff --git a/wherehows-web/app/constants/datasets/owner.ts b/wherehows-web/app/constants/datasets/owner.ts new file mode 100644 index 0000000000..3f93d4fbee --- /dev/null +++ b/wherehows-web/app/constants/datasets/owner.ts @@ -0,0 +1,131 @@ +import { IOwner } from 'wherehows-web/typings/api/datasets/owners'; +import { OwnerIdType, OwnerSource, OwnerType, OwnerUrnNamespace } from 'wherehows-web/utils/api/datasets/owners'; +import { isListUnique } from 'wherehows-web/utils/array'; + +/** + * Initial user name for candidate owners + * @type {string} + */ +const defaultOwnerUserName = 'New Owner'; + +/** + * The minimum required number of owners + * @type {number} + */ +const minRequiredConfirmedOwners = 2; + +/** + * Class to toggle readonly mode vs edit mode + * @type {string} + */ +const userNameEditableClass = 'dataset-author-cell--editing'; + +/** + * Checks that a userName already exists in the list of IOwner instances + * @param {Array} owners the list of owners + * @param {Pick} newOwner userName for the owner + * @returns {boolean} true if owner username in current list of owners + */ +const ownerAlreadyExists = (owners: Array, newOwner: Pick) => { + const newUserNameRegEx = new RegExp(`.*${newOwner.userName}.*`, 'i'); + + return owners.mapBy('userName').some((userName: string) => newUserNameRegEx.test(userName)); +}; + +// overloads +function updateOwner(owners: Array, owner: IOwner, props: IOwner): void | Array; +function updateOwner( + owners: Array, + owner: IOwner, + props: K, + value: IOwner[K] +): void | Array; +/** + * Updates an IOwner instance in a list of IOwners using a known key and expected value type, + * or a replacement attributes + * @template K + * @param {Array} owners the list containing the owners + * @param {IOwner} owner the owner to update + * @param {(K | IOwner)} props the properties to replace the IOwner instance with, or a singe IOwner attribute + * @param {IOwner[K]} [value] optional value to update the attribute with + * @returns {(void | Array)} the updated list of owners if the owner list contains no duplicates + */ +function updateOwner( + owners: Array, + owner: IOwner, + props: K | IOwner, + value?: IOwner[K] +): void | Array { + // creates a local working copy of the list of owners + const updatingOwners = [...owners]; + + // ensure that the owner is in the list by referential equality + if (updatingOwners.includes(owner)) { + const ownerPosition = updatingOwners.indexOf(owner); + let updatedOwner: IOwner; + + // if props is a string, i.e. attribute IOwner, override the previous value, + // otherwise replace with new attributes + if (typeof props === 'string') { + updatedOwner = { ...owner, [props]: value }; + } else { + updatedOwner = props; + } + + // retain update position + const updatedOwners: Array = [ + ...updatingOwners.slice(0, ownerPosition), + updatedOwner, + ...updatingOwners.slice(ownerPosition + 1) + ]; + + // each owner is uniquely identified by the composite key of userName and source + const userKeys = updatedOwners.map(({ userName, source }) => `${userName}:${source}`); + + // ensure we have not duplicates + if (isListUnique(userKeys)) { + return owners.setObjects(updatedOwners); + } + } +} + +/** + * Sets the `confirmedBy` attribute to the currently logged in user + * @param {Array} owners the list of owners + * @param {IOwner} owner the owner to be updated + * @param {string} confirmedBy the userName of the confirming user + * @returns {(Array | void)} + */ +const confirmOwner = (owners: Array, owner: IOwner, confirmedBy: string): Array | void => { + const isConfirmedBy = confirmedBy || null; + return updateOwner(owners, owner, 'confirmedBy', isConfirmedBy); + // return set(owner, 'confirmedBy', isConfirmedBy); +}; + +/** + * Defines the default properties for a newly created IOwner instance + *@type {IOwner} + */ +const defaultOwnerProps: IOwner = { + userName: defaultOwnerUserName, + email: null, + name: '', + isGroup: false, + namespace: OwnerUrnNamespace.groupUser, + type: OwnerType.Owner, + subType: null, + sortId: 0, + source: OwnerSource.Ui, + confirmedBy: null, + idType: OwnerIdType.User +}; + +export { + defaultOwnerProps, + defaultOwnerUserName, + minRequiredConfirmedOwners, + userNameEditableClass, + ownerAlreadyExists, + updateOwner, + confirmOwner +}; diff --git a/wherehows-web/app/services/user-lookup.ts b/wherehows-web/app/services/user-lookup.ts index 5437c8814b..8a91cc0fbe 100644 --- a/wherehows-web/app/services/user-lookup.ts +++ b/wherehows-web/app/services/user-lookup.ts @@ -10,9 +10,14 @@ import { IPartyEntity, IPartyProps } from 'wherehows-web/typings/api/datasets/pa * @param {Function} asyncResults callback * @return {Promise} */ -const ldapResolver = async (userNameQuery: string, _syncResults: Function, asyncResults: Function): Promise => { +const ldapResolver = async ( + userNameQuery: string, + _syncResults: Function, + asyncResults: (results: Array) => void +): Promise => { const ldapRegex = new RegExp(`^${userNameQuery}.*`, 'i'); const { userEntitiesSource = [] }: IPartyProps = await getUserEntities(); + asyncResults(userEntitiesSource.filter((entity: string) => ldapRegex.test(entity))); }; diff --git a/wherehows-web/app/styles/components/dataset-author/_all.scss b/wherehows-web/app/styles/components/dataset-author/_all.scss index 7ea1a8e716..9bbf2321d8 100644 --- a/wherehows-web/app/styles/components/dataset-author/_all.scss +++ b/wherehows-web/app/styles/components/dataset-author/_all.scss @@ -1 +1,2 @@ -@import "owner-table"; \ No newline at end of file +@import 'owner-table'; +@import 'dataset-author'; diff --git a/wherehows-web/app/styles/components/dataset-author/_dataset-author.scss b/wherehows-web/app/styles/components/dataset-author/_dataset-author.scss new file mode 100644 index 0000000000..31c83d37fe --- /dev/null +++ b/wherehows-web/app/styles/components/dataset-author/_dataset-author.scss @@ -0,0 +1,23 @@ +.dataset-author { + margin-top: item-spacing(7); + + &__header { + display: flex; + font-size: 20px; + font-weight: fw(normal, 4); + } +} + +.dataset-author-record { + &--disabled { + color: set-color(grey, mid); + font-weight: fw(italic, 2); + pointer-events: none; + } + + &#{&} &__action { + &--disabled { + background-color: set-color(grey, mid); + } + } +} diff --git a/wherehows-web/app/styles/components/dataset-author/_owner-table.scss b/wherehows-web/app/styles/components/dataset-author/_owner-table.scss index 9b30efec6f..4a29dc6b80 100644 --- a/wherehows-web/app/styles/components/dataset-author/_owner-table.scss +++ b/wherehows-web/app/styles/components/dataset-author/_owner-table.scss @@ -58,3 +58,18 @@ $user-name-width: 170px; width: 9%; } } + +.dataset-owner-table { + &#{&} { + margin-top: item-spacing(4); + box-shadow: 0 1px 1px 0 rgba(0, 0, 0, 0.16), 0 0 0 1px rgba(0, 0, 0, 0.08); + transition: box-shadow 200ms cubic-bezier(0.4, 0, 0.2, 1); + border: 0; + } + + &#{&} td, + &#{&} th { + padding: item-spacing(5 4); + font-weight: fw(normal, 4); + } +} diff --git a/wherehows-web/app/templates/components/dataset-author.hbs b/wherehows-web/app/templates/components/dataset-author.hbs index e90170acbc..2ddd1eefa5 100644 --- a/wherehows-web/app/templates/components/dataset-author.hbs +++ b/wherehows-web/app/templates/components/dataset-author.hbs @@ -1,148 +1,64 @@ -
-
-
- + + {{else}} + + {{#if isConfirmedSuggestedOwner}} + + - -
-
- - - {{#if errorMessage}} - {{/if}} + - {{#if actionMessage}} - - {{/if}} - - - - - - - - - - - - - - - - {{#each owners as |owner|}} - - - - - - - - - - - - {{/each}} - -
LDAP UsernameFull NameID TypeSourceLast Modified - Owner Type - - - - - - - - - Confirm?Remove
- - - {{aupac-typeahead - source=userNamesResolver - action=(action "editUserName" owner) - autoFocus=true - async=true - limit=10 - minLength=2 - placeholder="Find user by LDAP" - title="username" - class="form-control dataset-author-cell__user-name"}} - {{owner.name}}{{owner.idType}}{{owner.source}} - {{!-- e.g Jul 18th 2016, 11:11 am --}} - {{moment-calendar owner.modifiedTime sameElse="MMM Do YYYY, h:mm a"}} - - {{ember-selector - values=ownerTypes - selected=owner.type - change=(action "updateOwnerType" owner)}} - - {{input - type="checkbox" - title=(if owner.confirmedBy owner.confirmedBy "Not confirmed") - checked=(readonly owner.confirmedBy) - change=(action "confirmOwner" owner)}} - - -
-
diff --git a/wherehows-web/app/templates/components/dataset-authors.hbs b/wherehows-web/app/templates/components/dataset-authors.hbs new file mode 100644 index 0000000000..51812034d9 --- /dev/null +++ b/wherehows-web/app/templates/components/dataset-authors.hbs @@ -0,0 +1,118 @@ +
+
+

+ Owners Confirmed +

+ + +
+ + + + + + + + + + + + + + + {{#each confirmedOwners as |confirmedOwner|}} + + + {{dataset-author + owner=confirmedOwner + ownerTypes=ownerTypes + removeOwner=(action "removeOwner") + confirmSuggestedOwner=(action "confirmSuggestedOwner") + updateOwnerType=(action "updateOwnerType") + }} + + {{else}} + {{/each}} +
LDAP UsernameFull NameID TypeSourceLast Modified + Ownership Type + + + + + + + + + Remove Owner
+
+ +
+
+

+ System Suggested Owners +

+ + +
+ + + + + + + + + + + + + + + + {{#each systemGeneratedOwners as |systemGeneratedOwner|}} + {{dataset-author + owner=systemGeneratedOwner + ownerTypes=ownerTypes + commonOwners=commonOwners + removeOwner=(action "removeOwner") + confirmSuggestedOwner=(action "confirmSuggestedOwner") + updateOwnerType=(action "updateOwnerType") + }} + {{/each}} + + +
LDAP UsernameFull NameID TypeSourceLast Modified + Owner Type + + + + + + + + + Add Suggested Owner
+
+ +
+
+ + + +
+
+{{!--disabled={{ownershipIsInvalid}}--}} diff --git a/wherehows-web/app/templates/components/dataset-compliance.hbs b/wherehows-web/app/templates/components/dataset-compliance.hbs index c86b42dd86..44e8b89178 100644 --- a/wherehows-web/app/templates/components/dataset-compliance.hbs +++ b/wherehows-web/app/templates/components/dataset-compliance.hbs @@ -61,7 +61,7 @@ Last saved: {{if isNewComplianceInfo 'Never' - (moment-from-now policyModificationTimeInEpoch)}} + (moment-from-now complianceInfo.modifiedTime)}} diff --git a/wherehows-web/app/templates/datasets/dataset.hbs b/wherehows-web/app/templates/datasets/dataset.hbs index 7a824dae98..e48d8f1c82 100644 --- a/wherehows-web/app/templates/datasets/dataset.hbs +++ b/wherehows-web/app/templates/datasets/dataset.hbs @@ -223,12 +223,9 @@ }}
- {{dataset-author + {{dataset-authors owners=owners ownerTypes=ownerTypes - showMsg=showMsg - alertType=alertType - ownerMessage=ownerMessage save=(action "saveOwnerChanges") }}
diff --git a/wherehows-web/app/typings/api/datasets/owners.d.ts b/wherehows-web/app/typings/api/datasets/owners.d.ts index 08b0eab021..6d9d180899 100644 --- a/wherehows-web/app/typings/api/datasets/owners.d.ts +++ b/wherehows-web/app/typings/api/datasets/owners.d.ts @@ -1,32 +1,22 @@ import { ApiStatus } from 'wherehows-web/utils/api/shared'; -import { OwnerType } from 'wherehows-web/utils/api/datasets/owners'; - -/** - * Accepted string values for the Owner type - */ -type OwnerTypeLiteral = OwnerType.User | OwnerType.Group; - -/** - * Accepted string values for the namespace of a user - */ -type OwnerUrnLiteral = 'urn:li:corpuser' | 'urn:li:corpGroup'; +import { OwnerIdType, OwnerSource, OwnerType, OwnerUrnNamespace } from 'wherehows-web/utils/api/datasets/owners'; /** * Describes the interface for an Owner entity */ export interface IOwner { confirmedBy: null | string; - email: string; - idType: OwnerTypeLiteral; - isActive: boolean; + email: null | string; + idType: OwnerIdType; + isActive?: boolean; isGroup: boolean; - modifiedTime: number | Date; + modifiedTime?: number | Date; name: string; - namespace: OwnerUrnLiteral; + namespace: OwnerUrnNamespace; sortId: null | number; - source: string; + source: OwnerSource; subType: null; - type: string; + type: OwnerType; userName: string; } diff --git a/wherehows-web/app/typings/api/datasets/party-entities.d.ts b/wherehows-web/app/typings/api/datasets/party-entities.d.ts index 29db48e72a..bfbbfefa71 100644 --- a/wherehows-web/app/typings/api/datasets/party-entities.d.ts +++ b/wherehows-web/app/typings/api/datasets/party-entities.d.ts @@ -18,9 +18,9 @@ export interface IPartyEntityResponse { } /** - * Describes a userEntityMap interface + * Describes a IUserEntityMap interface */ -export interface userEntityMap { +export interface IUserEntityMap { [label: string]: string; } @@ -29,6 +29,6 @@ export interface userEntityMap { */ export interface IPartyProps { userEntities: Array; - userEntitiesMaps: userEntityMap; - userEntitiesSource: Array; + userEntitiesMaps: IUserEntityMap; + userEntitiesSource: Array; } diff --git a/wherehows-web/app/typings/global-plugin.d.ts b/wherehows-web/app/typings/global-plugin.d.ts new file mode 100644 index 0000000000..35e0bad9d1 --- /dev/null +++ b/wherehows-web/app/typings/global-plugin.d.ts @@ -0,0 +1,6 @@ +import Ember from 'ember'; + +// opt-in to allow types for Ember Array Prototype extensions +declare global { + interface Array extends Ember.ArrayPrototypeExtensions {} +} diff --git a/wherehows-web/app/utils/api/datasets/owners.ts b/wherehows-web/app/utils/api/datasets/owners.ts index 1211008750..e9613c85f5 100644 --- a/wherehows-web/app/utils/api/datasets/owners.ts +++ b/wherehows-web/app/utils/api/datasets/owners.ts @@ -5,18 +5,51 @@ import { IPartyEntity, IPartyEntityResponse, IPartyProps, - userEntityMap + IUserEntityMap } from 'wherehows-web/typings/api/datasets/party-entities'; import { IOwner, IOwnerResponse } from 'wherehows-web/typings/api/datasets/owners'; /** * Defines a string enum for valid owner types */ -export enum OwnerType { +export enum OwnerIdType { User = 'USER', Group = 'GROUP' } +/** + * Defines the string enum for the OwnerType attribute + * @type {string} + */ +export enum OwnerType { + Owner = 'Owner', + Consumer = 'Consumer', + Delegate = 'Delegate', + Producer = 'Producer', + Stakeholder = 'Stakeholder' +} + +/** + * Accepted string values for the namespace of a user + */ +export enum OwnerUrnNamespace { + corpUser = 'urn:li:corpuser', + groupUser = 'urn:li:corpGroup' +} + +export enum OwnerSource { + Scm = 'SCM', + Nuage = 'NUAGE', + Sos = 'SOS', + Db = 'DB', + Audit = 'AUDIT', + Jira = 'JIRA', + RB = 'RB', + Ui = 'UI', + Fs = 'FS', + Other = 'OTHER' +} + const { $: { getJSON } } = Ember; /** @@ -39,7 +72,7 @@ export const getDatasetOwners = async (id: number): Promise> => { return status === ApiStatus.OK ? owners.map(owner => ({ ...owner, - modifiedTime: new Date(owner.modifiedTime) + modifiedTime: new Date(owner.modifiedTime!) })) : Promise.reject(status); }; @@ -65,7 +98,7 @@ export const getUserEntities: () => Promise = (() => { * Memoized reference to the resolved value of a previous invocation to curried function in getUserEntities * @type {{result: IPartyProps | null}} */ - const cache: { result: IPartyProps | null; userEntitiesSource: Array } = { + const cache: { result: IPartyProps | null; userEntitiesSource: Array } = { result: null, userEntitiesSource: [] }; @@ -108,9 +141,9 @@ export const getUserEntities: () => Promise = (() => { /** * Transforms a list of party entities into a map of entity label to displayName value * @param {Array} partyEntities - * @return {Object} + * @return {IUserEntityMap} */ -export const getPartyEntitiesMap = (partyEntities: Array): userEntityMap => +export const getPartyEntitiesMap = (partyEntities: Array): IUserEntityMap => partyEntities.reduce( (map: { [label: string]: string }, { label, displayName }: IPartyEntity) => ((map[label] = displayName), map), {} @@ -122,5 +155,6 @@ export const getPartyEntitiesMap = (partyEntities: Array): userEnt * @return {boolean} */ export const isRequiredMinOwnersNotConfirmed = (owners: Array = []): boolean => - owners.filter(({ confirmedBy, type, idType }) => confirmedBy && type === 'Owner' && idType === OwnerType.User) - .length < minRequiredConfirmed; + owners.filter( + ({ confirmedBy, type, idType }) => confirmedBy && type === OwnerType.Owner && idType === OwnerIdType.User + ).length < minRequiredConfirmed; diff --git a/wherehows-web/mirage/fixtures/owners.ts b/wherehows-web/mirage/fixtures/owners.ts new file mode 100644 index 0000000000..819f9f97fb --- /dev/null +++ b/wherehows-web/mirage/fixtures/owners.ts @@ -0,0 +1,33 @@ +import { IOwner } from 'wherehows-web/typings/api/datasets/owners'; +import { OwnerSource, OwnerIdType, OwnerUrnNamespace } from 'wherehows-web/utils/api/datasets/owners'; + +export default >[ + { + confirmedBy: '', + email: 'confirmed-owner@linkedin.com', + idType: OwnerIdType.User, + isActive: true, + isGroup: true, + modifiedTime: Date.now(), + name: 'confirmed owner', + userName: 'fakeconfirmedowner', + namespace: OwnerUrnNamespace.corpUser, + source: OwnerSource.Ui, + subType: null, + type: 'Owner' + }, + { + confirmedBy: '', + email: 'suggested-owner@linkedin.com', + idType: OwnerIdType.User, + isActive: true, + isGroup: true, + modifiedTime: Date.now(), + name: 'suggested owner', + userName: 'fakesuggestedowner', + namespace: OwnerUrnNamespace.corpUser, + source: OwnerSource.Nuage, + subType: null, + type: 'Owner' + } +]; diff --git a/wherehows-web/mirage/fixtures/users.ts b/wherehows-web/mirage/fixtures/users.ts new file mode 100644 index 0000000000..205af5ca08 --- /dev/null +++ b/wherehows-web/mirage/fixtures/users.ts @@ -0,0 +1,15 @@ +import { IUser } from 'wherehows-web/typings/api/authentication/user'; + +export default >[ + { + departmentNum: 42, + email: 'hitchiker@linkedin.com', + id: 1337, + name: 'fake user', + userName: 'p0wn', + userSetting: { + defaultWatch: '', + detailDefaultView: '' + } + } +]; diff --git a/wherehows-web/package.json b/wherehows-web/package.json index e4b73daea2..b99ace533f 100644 --- a/wherehows-web/package.json +++ b/wherehows-web/package.json @@ -20,6 +20,7 @@ "devDependencies": { "@types/ember": "^2.8.0", "@types/ember-testing-helpers": "^0.0.3", + "@types/lodash": "^4.14.83", "@types/rsvp": "^4.0.0", "babel-eslint": "^8.0.1", "babel-plugin-transform-class-properties": "^6.24.1", @@ -51,8 +52,8 @@ "ember-export-application-global": "^2.0.0", "ember-fetch": "^3.4.3", "ember-load-initializers": "^1.0.0", - "ember-lodash-shim": "^2.0.5", "ember-metrics": "^0.12.1", + "ember-native-dom-helpers": "^0.5.4", "ember-pikaday": "^2.2.1", "ember-redux-shim": "^1.1.1", "ember-redux-thunk-shim": "^1.1.2", diff --git a/wherehows-web/tests/integration/components/dataset-author-test.js b/wherehows-web/tests/integration/components/dataset-author-test.js new file mode 100644 index 0000000000..f7e4d3cb83 --- /dev/null +++ b/wherehows-web/tests/integration/components/dataset-author-test.js @@ -0,0 +1,91 @@ +import { moduleForComponent, test } from 'ember-qunit'; +import hbs from 'htmlbars-inline-precompile'; +import { triggerEvent } from 'ember-native-dom-helpers'; +import { run } from '@ember/runloop'; + +import noop from 'wherehows-web/utils/noop'; +import owners from 'wherehows-web/mirage/fixtures/owners'; +import { OwnerType } from 'wherehows-web/utils/api/datasets/owners'; + +const [confirmedOwner, suggestedOwner] = owners; +const commonOwners = []; +const ownerTypes = Object.values(OwnerType); + +moduleForComponent('dataset-author', 'Integration | Component | dataset author', { + integration: true +}); + +test('it renders', function(assert) { + this.set('removeOwner', noop); + this.set('confirmSuggestedOwner', noop); + this.set('owner', confirmedOwner); + this.set('commonOwners', commonOwners); + + this.render( + hbs`{{dataset-author confirmSuggestedOwner=confirmSuggestedOwner removeOwner=removeOwner owner=owner commonOwners=commonOwners}}` + ); + + assert.equal(document.querySelector('tr.dataset-author-record').tagName, 'TR'); +}); + +test('triggers the removeOwner action when invoked', function(assert) { + assert.expect(2); + let removeActionCallCount = 0; + + this.set('removeOwner', () => { + removeActionCallCount++; + assert.equal(removeActionCallCount, 1, 'action is called once'); + }); + this.set('confirmSuggestedOwner', noop); + this.set('owner', confirmedOwner); + this.set('commonOwners', commonOwners); + + this.render( + hbs`{{dataset-author confirmSuggestedOwner=confirmSuggestedOwner removeOwner=removeOwner owner=owner commonOwners=commonOwners}}` + ); + + assert.equal(removeActionCallCount, 0, 'action is not called on render'); + + triggerEvent('.remove-dataset-author', 'click'); +}); + +test('triggers the confirmSuggestedOwner action when invoked', function(assert) { + assert.expect(2); + let confirmSuggestedOwnerActionCallCount = 0; + + this.set('removeOwner', noop); + this.set('confirmSuggestedOwner', () => { + confirmSuggestedOwnerActionCallCount++; + assert.equal(confirmSuggestedOwnerActionCallCount, 1, 'action is called once'); + }); + this.set('owner', suggestedOwner); + this.set('commonOwners', commonOwners); + + this.render( + hbs`{{dataset-author confirmSuggestedOwner=confirmSuggestedOwner removeOwner=removeOwner owner=owner commonOwners=commonOwners}}` + ); + + assert.equal(confirmSuggestedOwnerActionCallCount, 0, 'action is not called on render'); + + triggerEvent('.confirm-suggested-dataset-author', 'click'); +}); + +test('triggers the updateOwnerType action when invoked', function(assert) { + assert.expect(2); + + this.set('removeOwner', noop); + this.set('confirmSuggestedOwner', noop); + this.set('updateOwnerType', (owner, type) => { + assert.ok(confirmedOwner === owner, 'updateOwnerType action is invoked correct owner reference'); + assert.equal(type, confirmedOwner.type, 'updateOwnerType action is invoked with selected type'); + }); + this.set('owner', confirmedOwner); + this.set('commonOwners', commonOwners); + this.set('ownerTypes', ownerTypes); + + this.render( + hbs`{{dataset-author confirmSuggestedOwner=confirmSuggestedOwner removeOwner=removeOwner owner=owner commonOwners=commonOwners updateOwnerType=updateOwnerType ownerTypes=ownerTypes}}` + ); + + triggerEvent('select', 'change'); +}); diff --git a/wherehows-web/tests/integration/components/dataset-authors-test.js b/wherehows-web/tests/integration/components/dataset-authors-test.js new file mode 100644 index 0000000000..c8a236fada --- /dev/null +++ b/wherehows-web/tests/integration/components/dataset-authors-test.js @@ -0,0 +1,84 @@ +import { moduleForComponent, test } from 'ember-qunit'; +import hbs from 'htmlbars-inline-precompile'; +import { run } from '@ember/runloop'; + +import noop from 'wherehows-web/utils/noop'; +import { OwnerType, OwnerSource } from 'wherehows-web/utils/api/datasets/owners'; +import owners from 'wherehows-web/mirage/fixtures/owners'; + +import userStub from 'wherehows-web/tests/stubs/services/current-user'; + +const [confirmedOwner] = owners; +const ownerTypes = Object.values(OwnerType); + +moduleForComponent('dataset-authors', 'Integration | Component | dataset authors', { + integration: true, + + beforeEach() { + this.register('service:current-user', userStub); + + this.inject.service('current-user'); + } +}); + +test('it renders', function(assert) { + assert.expect(1); + this.set('owners', owners); + this.set('ownerTypes', ownerTypes); + this.set('saveOwnerChanges', noop); + this.render(hbs`{{dataset-authors owners=owners ownerTypes=ownerTypes save=(action saveOwnerChanges)}}`); + + assert.equal(this.$('.dataset-author').length, 2, 'expected two dataset author components to be rendered'); +}); + +test('it should remove an owner when removeOwner is invoked', function(assert) { + assert.expect(1); + this.set('owners', [confirmedOwner]); + this.set('ownerTypes', ownerTypes); + this.set('saveOwnerChanges', noop); + this.render(hbs`{{dataset-authors owners=owners ownerTypes=ownerTypes save=(action saveOwnerChanges)}}`); + + run(() => { + document.querySelector('.remove-dataset-author').click(); + }); + + assert.equal(this.get('owners').length, 0); +}); + +test('it should update a suggested owner to confirmed', function(assert) { + assert.expect(3); + + const initialLength = owners.length; + this.set('owners', owners); + this.set('ownerTypes', ownerTypes); + this.set('saveOwnerChanges', noop); + this.render(hbs`{{dataset-authors owners=owners ownerTypes=ownerTypes save=(action saveOwnerChanges)}}`); + + assert.equal( + this.get('owners.length'), + initialLength, + `the list of owners is ${initialLength} before adding confirmed owner` + ); + run(() => { + document.querySelector('.confirm-suggested-dataset-author').click(); + }); + + assert.equal(this.get('owners.length'), initialLength + 1, 'the list of owner contains one more new owner'); + assert.equal(this.get('owners.lastObject.source'), OwnerSource.Ui, 'contains a new owner with ui source'); +}); + +test('it should invoke the external save action on save', function(assert) { + assert.expect(2); + this.set('owners', [confirmedOwner]); + this.set('ownerTypes', ownerTypes); + this.set('saveOwnerChanges', owners => { + assert.ok(owners === this.get('owners'), 'the list of owners is passed into the save action'); + }); + this.render(hbs`{{dataset-authors owners=owners ownerTypes=ownerTypes save=(action saveOwnerChanges)}}`); + + run(() => { + document.querySelector('.dataset-authors-save').click(); + }); + + assert.equal(this.get('owners').length, 1); +}); diff --git a/wherehows-web/tests/stubs/services/current-user.js b/wherehows-web/tests/stubs/services/current-user.js new file mode 100644 index 0000000000..2ab4cf6787 --- /dev/null +++ b/wherehows-web/tests/stubs/services/current-user.js @@ -0,0 +1,11 @@ +import Service from '@ember/service'; +import users from 'wherehows-web/mirage/fixtures/users'; + +const [user] = users; + +export default class extends Service { + currentUser = user; + load = () => Promise.resolve(); + invalidateSession = () => {}; + trackCurrentUser = () => {}; +} diff --git a/wherehows-web/yarn.lock b/wherehows-web/yarn.lock index 41710e9621..f35d5078c8 100644 --- a/wherehows-web/yarn.lock +++ b/wherehows-web/yarn.lock @@ -113,6 +113,10 @@ version "3.2.15" resolved "https://registry.yarnpkg.com/@types/jquery/-/jquery-3.2.15.tgz#3f620a9f5a0b296866f4bc729825226d0a35fba6" +"@types/lodash@^4.14.83": + version "4.14.83" + resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.83.tgz#2f2154797ce8fd8d6ea91a8d304a4e44ee95920c" + "@types/rsvp@*", "@types/rsvp@^4.0.0": version "4.0.0" resolved "https://registry.yarnpkg.com/@types/rsvp/-/rsvp-4.0.0.tgz#6c59d84bb5ea8a4fd11ec3d7aa748710e0e5e373" @@ -2976,13 +2980,6 @@ ember-load-initializers@^1.0.0: dependencies: ember-cli-babel "^6.0.0-beta.7" -ember-lodash-shim@^2.0.5: - version "2.0.5" - resolved "https://registry.yarnpkg.com/ember-lodash-shim/-/ember-lodash-shim-2.0.5.tgz#c5015c5e91e09510238885b5de9c831538a0156c" - dependencies: - ember-cli-babel "^5.1.7" - ember-cli-htmlbars "^1.1.1" - ember-lodash@4.17.2: version "4.17.2" resolved "https://registry.yarnpkg.com/ember-lodash/-/ember-lodash-4.17.2.tgz#0ed40ab89c2f9846765fc2504c0034000f666933"