Merge pull request #771 from theseyi/compliance-reviewable

adds reviewable filter option. updates filter logic to create diff between fields on policy and column field values. refactors implementation for client working copy / change-set: reduces number of steps. modifies compliance entity table styles
This commit is contained in:
Seyi Adebajo 2017-09-27 10:55:05 -07:00 committed by GitHub
commit 25d1e44fcb
19 changed files with 346 additions and 160 deletions

View File

@ -1,36 +1,19 @@
import Ember from 'ember'; import Ember from 'ember';
import DatasetTableRow from 'wherehows-web/components/dataset-table-row'; import DatasetTableRow from 'wherehows-web/components/dataset-table-row';
import { import {
fieldIdentifierTypes, fieldIdentifierTypeValues,
fieldIdentifierTypeIds,
defaultFieldDataTypeClassification, defaultFieldDataTypeClassification,
isMixedId, isMixedId,
isCustomId, isCustomId,
hasPredefinedFieldFormat, hasPredefinedFieldFormat,
logicalTypesForIds, logicalTypesForIds,
logicalTypesForGeneric logicalTypesForGeneric,
SuggestionIntent
} from 'wherehows-web/constants'; } from 'wherehows-web/constants';
import { fieldIdentifierTypeIds } from 'wherehows-web/components/dataset-compliance'; import { fieldChangeSetRequiresReview } from 'wherehows-web/utils/datasets/compliance-policy';
const { computed, get, getProperties, set } = Ember; const { computed, get, getProperties } = Ember;
/**
* String indicating that the user affirms a suggestion
* @type {string}
*/
const acceptIntent = 'accept';
/**
* String indicating that the user ignored a suggestion
* @type {string}
*/
const ignoreIntent = 'ignore';
/**
* Caches a list of fieldIdentifierTypes values
* @type {Array<string>}
*/
const fieldIdentifierTypeValues = Object.keys(fieldIdentifierTypes)
.map(fieldIdentifierType => fieldIdentifierTypes[fieldIdentifierType])
.mapBy('value');
/** /**
* Extracts the suggestions for identifierType, logicalType suggestions, and confidence from a list of predictions * Extracts the suggestions for identifierType, logicalType suggestions, and confidence from a list of predictions
@ -77,23 +60,23 @@ export default DatasetTableRow.extend({
suggestionAuthority: computed.alias('field.suggestionAuthority'), suggestionAuthority: computed.alias('field.suggestionAuthority'),
/** /**
* Checks that the field does not have a recently input value * Checks that the field does not have a current policy value
* @type {Ember.computed} * @type {Ember.computed}
* @return {boolean} * @return {boolean}
*/ */
isNewField: computed('isNewComplianceInfo', 'isModified', function() { isReviewRequested: computed('field.{isDirty,suggestion,privacyPolicyExists,suggestionAuthority}', function() {
const { isNewComplianceInfo, isModified } = getProperties(this, ['isNewComplianceInfo', 'isModified']); return fieldChangeSetRequiresReview(get(this, 'field'));
return isNewComplianceInfo && !isModified;
}), }),
/** /**
* Maps the suggestion response to a string resolution * Maps the suggestion response to a string resolution
* @type {Ember.computed} * @type {Ember.computed}
* @return {string|void}
*/ */
suggestionResolution: computed('field.suggestionAuthority', function() { suggestionResolution: computed('field.suggestionAuthority', function() {
return { return {
[acceptIntent]: 'Accepted', [SuggestionIntent.accept]: 'Accepted',
[ignoreIntent]: 'Discarded' [SuggestionIntent.ignore]: 'Discarded'
}[get(this, 'field.suggestionAuthority')]; }[get(this, 'field.suggestionAuthority')];
}), }),
@ -204,7 +187,6 @@ export default DatasetTableRow.extend({
const { onFieldIdentifierTypeChange } = this.attrs; const { onFieldIdentifierTypeChange } = this.attrs;
if (typeof onFieldIdentifierTypeChange === 'function') { if (typeof onFieldIdentifierTypeChange === 'function') {
onFieldIdentifierTypeChange(get(this, 'field'), { value }); onFieldIdentifierTypeChange(get(this, 'field'), { value });
set(this, 'isModified', true);
} }
}, },
@ -217,7 +199,6 @@ export default DatasetTableRow.extend({
const { onFieldLogicalTypeChange } = this.attrs; const { onFieldLogicalTypeChange } = this.attrs;
if (typeof onFieldLogicalTypeChange === 'function') { if (typeof onFieldLogicalTypeChange === 'function') {
onFieldLogicalTypeChange(get(this, 'field'), { value }); onFieldLogicalTypeChange(get(this, 'field'), { value });
set(this, 'isModified', true);
} }
}, },
@ -229,7 +210,6 @@ export default DatasetTableRow.extend({
const { onFieldClassificationChange } = this.attrs; const { onFieldClassificationChange } = this.attrs;
if (typeof onFieldClassificationChange === 'function') { if (typeof onFieldClassificationChange === 'function') {
onFieldClassificationChange(get(this, 'field'), { value }); onFieldClassificationChange(get(this, 'field'), { value });
set(this, 'isModified', true);
} }
}, },
@ -242,7 +222,7 @@ export default DatasetTableRow.extend({
const { onSuggestionIntent } = this.attrs; const { onSuggestionIntent } = this.attrs;
// Accept the suggestion for either identifierType and/or logicalType // Accept the suggestion for either identifierType and/or logicalType
if (intent === acceptIntent) { if (intent === SuggestionIntent.accept) {
const { identifierType, logicalType } = get(this, 'prediction'); const { identifierType, logicalType } = get(this, 'prediction');
if (identifierType) { if (identifierType) {
this.actions.onFieldIdentifierTypeChange.call(this, { value: identifierType }); this.actions.onFieldIdentifierTypeChange.call(this, { value: identifierType });

View File

@ -4,6 +4,7 @@ import {
classifiers, classifiers,
datasetClassifiers, datasetClassifiers,
fieldIdentifierTypes, fieldIdentifierTypes,
fieldIdentifierTypeIds,
idLogicalTypes, idLogicalTypes,
nonIdFieldLogicalTypes, nonIdFieldLogicalTypes,
defaultFieldDataTypeClassification, defaultFieldDataTypeClassification,
@ -13,8 +14,14 @@ import {
hasPredefinedFieldFormat, hasPredefinedFieldFormat,
getDefaultLogicalType getDefaultLogicalType
} from 'wherehows-web/constants'; } from 'wherehows-web/constants';
import { isPolicyExpectedShape } from 'wherehows-web/utils/datasets/functions'; import {
isPolicyExpectedShape,
fieldChangeSetRequiresReview,
mergeMappedColumnFieldsWithSuggestions
} from 'wherehows-web/utils/datasets/compliance-policy';
import scrollMonitor from 'scrollmonitor'; import scrollMonitor from 'scrollmonitor';
import { hasEnumerableKeys } from 'wherehows-web/utils/object';
import { arrayFilter, isListUnique } from 'wherehows-web/utils/array';
const { const {
assert, assert,
@ -75,17 +82,17 @@ const datasetClassifiersKeys = Object.keys(datasetClassifiers);
* @type {string} * @type {string}
*/ */
const policyComplianceEntitiesKey = 'complianceInfo.complianceEntities'; const policyComplianceEntitiesKey = 'complianceInfo.complianceEntities';
/**
* Duplicate check using every to short-circuit iteration
* @param {Array} list = [] the list to check for dupes
* @return {Boolean} true is unique, false otherwise
*/
const listIsUnique = (list = []) => new Set(list).size === list.length;
assert('`fieldIdentifierTypes` contains an object with a key `none`', typeof fieldIdentifierTypes.none === 'object'); assert('`fieldIdentifierTypes` contains an object with a key `none`', typeof fieldIdentifierTypes.none === 'object');
const fieldIdentifierTypeKeysBarNone = Object.keys(fieldIdentifierTypes).filter(k => k !== 'none'); const fieldIdentifierTypeKeysBarNone = Object.keys(fieldIdentifierTypes).filter(k => k !== 'none');
const fieldDisplayKeys = ['none', '_', ...fieldIdentifierTypeKeysBarNone]; const fieldDisplayKeys = ['none', '_', ...fieldIdentifierTypeKeysBarNone];
/**
* Returns a list of changeSet fields that requires user attention
* @type {function({}): Array<{ isDirty, suggestion, privacyPolicyExists, suggestionAuthority }>}
*/
const changeSetFieldsRequiringReview = arrayFilter(fieldChangeSetRequiresReview);
/** /**
* A list of field identifier types mapped to label, value options for select display * A list of field identifier types mapped to label, value options for select display
* @type {any[]|Array.<{value: String, label: String}>} * @type {any[]|Array.<{value: String, label: String}>}
@ -103,15 +110,6 @@ const fieldIdentifierOptions = fieldDisplayKeys.map(fieldIdentifierType => {
}; };
}); });
/**
* A list of field identifier types that are Ids i.e member ID, org ID, group ID
* @type {any[]|Array.<String>}
*/
export const fieldIdentifierTypeIds = Object.keys(fieldIdentifierTypes)
.map(fieldIdentifierType => fieldIdentifierTypes[fieldIdentifierType])
.filter(({ isId }) => isId)
.mapBy('value');
export default Component.extend({ export default Component.extend({
sortColumnWithName: 'identifierField', sortColumnWithName: 'identifierField',
filterBy: 'identifierField', filterBy: 'identifierField',
@ -142,6 +140,7 @@ export default Component.extend({
* @return {boolean} * @return {boolean}
*/ */
isReadOnly: computed.not('isEditing'), isReadOnly: computed.not('isEditing'),
/** /**
* Flag indicating that the component is currently saving / attempting to save the privacy policy * Flag indicating that the component is currently saving / attempting to save the privacy policy
* @type {String} * @type {String}
@ -183,6 +182,25 @@ export default Component.extend({
})); }));
}), }),
/**
* Returns a list of ui values and labels for review filter drop-down
* @type {Ember.computed}
*/
fieldReviewOptions: computed(function() {
return [
{ value: 'showAll', label: 'Showing all fields' },
{
value: 'showReview',
label: 'Showing only fields to review'
}
];
}),
/**
* @type {string}
*/
fieldReviewOption: 'showAll',
/** /**
* Reference to the application notifications Service * Reference to the application notifications Service
* @type {Ember.Service} * @type {Ember.Service}
@ -272,7 +290,7 @@ export default Component.extend({
const fieldNames = getWithDefault(this, 'schemaFieldNamesMappedToDataTypes', []).mapBy('fieldName'); const fieldNames = getWithDefault(this, 'schemaFieldNamesMappedToDataTypes', []).mapBy('fieldName');
// identifier field names from the column api should be unique // identifier field names from the column api should be unique
if (listIsUnique(fieldNames.sort())) { if (isListUnique(fieldNames.sort())) {
return set(this, '_hasBadData', false); return set(this, '_hasBadData', false);
} }
@ -400,48 +418,13 @@ export default Component.extend({
}, []); }, []);
}), }),
/**
* Lists all dataset fields found in the `columns` performs an intersection
* of fields with the currently persisted and/or updated
* privacyCompliancePolicy.complianceEntities.
* The returned list is a map of fields with current or default privacy properties
*/
mergeComplianceEntitiesAndColumnFields(
columnIdFieldsToCurrentPrivacyPolicy = {},
truncatedColumnFields = [],
identifierFieldMappedToSuggestions = {}
) {
return truncatedColumnFields.map(({ fieldName: identifierField, dataType }) => {
const {
[identifierField]: { identifierType, logicalType, securityClassification }
} = columnIdFieldsToCurrentPrivacyPolicy;
//Cache the mapped suggestion into a local
const suggestion = identifierFieldMappedToSuggestions[identifierField];
let field = {
identifierField,
dataType,
identifierType,
logicalType,
classification: securityClassification
};
// If a suggestion exists for this field add the suggestion attribute to the field properties
if (suggestion) {
field = { ...field, suggestion };
}
return field;
});
},
/** /**
* *
* @param {Array} columnFieldNames * @param {Array<object>} columnFieldProps
* @return {*|{}|any} * @param {Array<object>} complianceEntities
* @return {object}
*/ */
mapColumnIdFieldsToCurrentPrivacyPolicy(columnFieldNames) { mapColumnIdFieldsToCurrentPrivacyPolicy(columnFieldProps, complianceEntities) {
const complianceEntities = get(this, policyComplianceEntitiesKey) || [];
const getKeysOnField = (keys = [], fieldName, source = []) => { const getKeysOnField = (keys = [], fieldName, source = []) => {
const sourceField = source.find(({ identifierField }) => identifierField === fieldName) || {}; const sourceField = source.find(({ identifierField }) => identifierField === fieldName) || {};
let ret = {}; let ret = {};
@ -454,14 +437,23 @@ export default Component.extend({
return ret; return ret;
}; };
return columnFieldNames.reduce((acc, identifierField) => { return columnFieldProps.reduce((acc, { identifierField, dataType }) => {
const currentPrivacyAttrs = getKeysOnField( const currentPrivacyAttrs = getKeysOnField(
['identifierType', 'logicalType', 'securityClassification'], ['identifierType', 'logicalType', 'securityClassification'],
identifierField, identifierField,
complianceEntities complianceEntities
); );
return { ...acc, ...{ [identifierField]: currentPrivacyAttrs } }; return {
...acc,
[identifierField]: {
identifierField,
dataType,
...currentPrivacyAttrs,
privacyPolicyExists: hasEnumerableKeys(currentPrivacyAttrs),
isDirty: false
}
};
}, {}); }, {});
}, },
@ -473,29 +465,58 @@ export default Component.extend({
'truncatedColumnFields', 'truncatedColumnFields',
`${policyComplianceEntitiesKey}.[]`, `${policyComplianceEntitiesKey}.[]`,
function() { function() {
const columnFieldNames = get(this, 'truncatedColumnFields').map(({ fieldName }) => fieldName); // Truncated list of Dataset field names and data types currently returned from the column endpoint
return this.mapColumnIdFieldsToCurrentPrivacyPolicy(columnFieldNames); const columnFieldProps = get(this, 'truncatedColumnFields').map(({ fieldName, dataType }) => ({
identifierField: fieldName,
dataType
}));
// Dataset fields that currently have a compliance policy
const currentComplianceEntities = get(this, policyComplianceEntitiesKey) || [];
return this.mapColumnIdFieldsToCurrentPrivacyPolicy(columnFieldProps, currentComplianceEntities);
} }
), ),
/** /**
* Caches a reference to the generated list of merged data between the column api and the current compliance entities list * Caches a reference to the generated list of merged data between the column api and the current compliance entities list
* @type {Ember.computed} * @type {Array<{identifierType: string, logicalType: string, securityClassification: string, privacyPolicyExists: boolean, isDirty: boolean, [suggestion]: object}>}
*/ */
mergedComplianceEntitiesAndColumnFields: computed('columnIdFieldsToCurrentPrivacyPolicy', function() { compliancePolicyChangeSet: computed('columnIdFieldsToCurrentPrivacyPolicy', function() {
// truncatedColumnFields is a dependency for cp columnIdFieldsToCurrentPrivacyPolicy, so no need to dep on that directly // truncatedColumnFields is a dependency for cp columnIdFieldsToCurrentPrivacyPolicy, so no need to dep on that directly
return this.mergeComplianceEntitiesAndColumnFields( return mergeMappedColumnFieldsWithSuggestions(
get(this, 'columnIdFieldsToCurrentPrivacyPolicy'), get(this, 'columnIdFieldsToCurrentPrivacyPolicy'),
get(this, 'truncatedColumnFields'),
get(this, 'identifierFieldToSuggestion') get(this, 'identifierFieldToSuggestion')
); );
}), }),
/**
* Returns a list of changeSet fields that meets the user selected filter criteria
* @type {Ember.computed}
* @return {Array<{}>}
*/
filteredChangeSet: computed('changeSetReviewCount', 'fieldReviewOption', 'compliancePolicyChangeSet', function() {
const changeSet = get(this, 'compliancePolicyChangeSet');
return get(this, 'fieldReviewOption') === 'showReview' ? changeSetFieldsRequiringReview(changeSet) : changeSet;
}),
/**
* Returns a count of changeSet fields that require user attention
* @type {Ember.computed}
* @return {Array<{}>}
*/
changeSetReviewCount: computed(
'compliancePolicyChangeSet.@each.{isDirty,suggestion,privacyPolicyExists,suggestionAuthority}',
function() {
return changeSetFieldsRequiringReview(get(this, 'compliancePolicyChangeSet')).length;
}
),
/** /**
* Creates a mapping of compliance suggestions to identifierField * Creates a mapping of compliance suggestions to identifierField
* This improves performance in a subsequent merge op since this loop * This improves performance in a subsequent merge op since this loop
* happens only once and is cached * happens only once and is cached
* @type {Ember.computed} * @type {object}
*/ */
identifierFieldToSuggestion: computed('complianceSuggestion', function() { identifierFieldToSuggestion: computed('complianceSuggestion', function() {
const identifierFieldToSuggestion = {}; const identifierFieldToSuggestion = {};
@ -603,7 +624,7 @@ export default Component.extend({
// All candidate fields that can be on policy, excluding tracking type fields // All candidate fields that can be on policy, excluding tracking type fields
const datasetFields = get( const datasetFields = get(
this, this,
'mergedComplianceEntitiesAndColumnFields' 'compliancePolicyChangeSet'
).map(({ identifierField, identifierType, logicalType, classification }) => ({ ).map(({ identifierField, identifierType, logicalType, classification }) => ({
identifierField, identifierField,
identifierType, identifierType,
@ -681,7 +702,7 @@ export default Component.extend({
complianceEntities.filter(({ identifierType }) => fieldIdentifierTypeIds.includes(identifierType)), complianceEntities.filter(({ identifierType }) => fieldIdentifierTypeIds.includes(identifierType)),
[...genericLogicalTypes, ...idLogicalTypes] [...genericLogicalTypes, ...idLogicalTypes]
); );
const fieldIdentifiersAreUnique = listIsUnique(complianceEntities.mapBy('identifierField')); const fieldIdentifiersAreUnique = isListUnique(complianceEntities.mapBy('identifierField'));
const schemaFieldLengthGreaterThanComplianceEntities = this.isSchemaFieldLengthGreaterThanComplianceEntities(); const schemaFieldLengthGreaterThanComplianceEntities = this.isSchemaFieldLengthGreaterThanComplianceEntities();
if (!fieldIdentifiersAreUnique || !schemaFieldLengthGreaterThanComplianceEntities) { if (!fieldIdentifiersAreUnique || !schemaFieldLengthGreaterThanComplianceEntities) {
@ -751,6 +772,14 @@ export default Component.extend({
return set(this, 'showAllDatasetMemberData', true); return set(this, 'showAllDatasetMemberData', true);
}, },
/**
* Updates the fieldReviewOption with the user selected value
* @param {string} value
*/
onFieldReviewChange({ value }) {
return set(this, 'fieldReviewOption', value);
},
/** /**
* Handler for setting the compliance policy into edit mode and rendering * Handler for setting the compliance policy into edit mode and rendering
*/ */
@ -862,17 +891,19 @@ export default Component.extend({
* @param {String} identifierType * @param {String} identifierType
*/ */
onFieldIdentifierTypeChange({ identifierField }, { value: identifierType }) { onFieldIdentifierTypeChange({ identifierField }, { value: identifierType }) {
const currentComplianceEntities = get(this, 'mergedComplianceEntitiesAndColumnFields'); const currentComplianceEntities = get(this, 'compliancePolicyChangeSet');
let logicalType;
// A reference to the current field in the compliance list, it should exist even for empty complianceEntities // A reference to the current field in the compliance list, it should exist even for empty complianceEntities
// since this is a reference created in the working copy: mergedComplianceEntitiesAndColumnFields // since this is a reference created in the working copy: compliancePolicyChangeSet
const currentFieldInComplianceList = currentComplianceEntities.findBy('identifierField', identifierField); const currentFieldInComplianceList = currentComplianceEntities.findBy('identifierField', identifierField);
let logicalType;
if (hasPredefinedFieldFormat(identifierType)) { if (hasPredefinedFieldFormat(identifierType)) {
logicalType = getDefaultLogicalType(identifierType); logicalType = getDefaultLogicalType(identifierType);
} }
setProperties(currentFieldInComplianceList, { setProperties(currentFieldInComplianceList, {
identifierType, identifierType,
logicalType logicalType,
isDirty: true
}); });
// Set the defaultClassification for the identifierField, // Set the defaultClassification for the identifierField,
// although the classification is based on the logicalType, // although the classification is based on the logicalType,
@ -898,7 +929,7 @@ export default Component.extend({
{ value: fieldIdentifierTypes.none.value } { value: fieldIdentifierTypes.none.value }
); );
} else { } else {
set(field, 'logicalType', logicalType); setProperties(field, { logicalType, isDirty: true });
} }
return this.setDefaultClassification({ identifierField }, { value: logicalType }); return this.setDefaultClassification({ identifierField }, { value: logicalType });
@ -911,7 +942,7 @@ export default Component.extend({
* @return {*} * @return {*}
*/ */
onFieldClassificationChange({ identifierField }, { value: classification = null }) { onFieldClassificationChange({ identifierField }, { value: classification = null }) {
const currentFieldInComplianceList = get(this, 'mergedComplianceEntitiesAndColumnFields').findBy( const currentFieldInComplianceList = get(this, 'compliancePolicyChangeSet').findBy(
'identifierField', 'identifierField',
identifierField identifierField
); );
@ -919,7 +950,7 @@ export default Component.extend({
this.clearMessages(); this.clearMessages();
// Apply the updated classification value to the current instance of the field in working copy // Apply the updated classification value to the current instance of the field in working copy
set(currentFieldInComplianceList, 'classification', classification); setProperties(currentFieldInComplianceList, { classification, isDirty: true });
}, },
/** /**

View File

@ -0,0 +1,10 @@
import Ember from 'ember';
const { TextField } = Ember;
export default TextField.extend({
/**
* Prevents click event bubbling
*/
click: () => false
});

View File

@ -1,4 +1,5 @@
import Ember from 'ember'; import Ember from 'ember';
import { arrayMap } from 'wherehows-web/utils/array';
/** /**
* Defines the string values that are allowed for a classification * Defines the string values that are allowed for a classification
@ -9,6 +10,14 @@ enum Classification {
HighlyConfidential = 'highlyConfidential' HighlyConfidential = 'highlyConfidential'
} }
/**
* String indicating that the user affirms or ignored a field suggestion
*/
enum SuggestionIntent {
accept = 'accept',
ignore = 'ignore'
}
/** /**
* Describes the index signature for the nonIdFieldLogicalTypes object * Describes the index signature for the nonIdFieldLogicalTypes object
*/ */
@ -18,6 +27,23 @@ interface INonIdLogicalTypes {
displayAs: string; displayAs: string;
}; };
} }
/**
* Describes the properties on a field identifier object for ui rendering
*/
interface IFieldIdProps {
value: string;
isId: boolean;
displayAs: string;
}
/**
* Describes the index signature for fieldIdentifierTypes
*/
interface IFieldIdTypes {
[prop: string]: IFieldIdProps;
}
/** /**
* A list of id logical types * A list of id logical types
* @type {Array.<String>} * @type {Array.<String>}
@ -154,12 +180,11 @@ const defaultFieldDataTypeClassification = Object.assign(
const classifiers = Object.values(defaultFieldDataTypeClassification).filter( const classifiers = Object.values(defaultFieldDataTypeClassification).filter(
(classifier, index, iter) => iter.indexOf(classifier) === index (classifier, index, iter) => iter.indexOf(classifier) === index
); );
/** /**
* A map of identifier types for fields on a dataset * A map of identifier types for fields on a dataset
* @type {{none: {value: string, isId: boolean, displayAs: string}, member: {value: string, isId: boolean, displayAs: string}, subjectMember: {value: string, isId: boolean, displayAs: string}, group: {value: string, isId: boolean, displayAs: string}, organization: {value: string, isId: boolean, displayAs: string}, generic: {value: string, isId: boolean, displayAs: string}}} * @type {{none: {value: string, isId: boolean, displayAs: string}, member: {value: string, isId: boolean, displayAs: string}, subjectMember: {value: string, isId: boolean, displayAs: string}, group: {value: string, isId: boolean, displayAs: string}, organization: {value: string, isId: boolean, displayAs: string}, generic: {value: string, isId: boolean, displayAs: string}}}
*/ */
const fieldIdentifierTypes = { const fieldIdentifierTypes: IFieldIdTypes = {
none: { none: {
value: 'NONE', value: 'NONE',
isId: false, isId: false,
@ -282,10 +307,36 @@ const logicalTypesForIds = logicalTypeValueLabel('id');
// Map generic logical type to options consumable in DOM // Map generic logical type to options consumable in DOM
const logicalTypesForGeneric = logicalTypeValueLabel('generic'); const logicalTypesForGeneric = logicalTypeValueLabel('generic');
/**
* Caches a list of field identifiers
* @type {Array<IFieldIdProps>}
*/
const fieldIdentifierTypesList: Array<IFieldIdProps> = arrayMap(
(fieldIdentifierType: string) => fieldIdentifierTypes[fieldIdentifierType]
)(Object.keys(fieldIdentifierTypes));
/**
* A list of field identifier types that are Ids i.e member ID, org ID, group ID
* @type {Array<Pick<IFieldIdProps, 'value'>>}
*/
const fieldIdentifierTypeIds: Array<Pick<IFieldIdProps, 'value'>> = fieldIdentifierTypesList
.filter(({ isId }) => isId)
.map(({ value }) => ({ value }));
/**
* Caches a list of fieldIdentifierTypes values
* @type {Array<Pick<IFieldIdProps, 'value'>>}
*/
const fieldIdentifierTypeValues: Array<Pick<IFieldIdProps, 'value'>> = fieldIdentifierTypesList.map(({ value }) => ({
value
}));
export { export {
defaultFieldDataTypeClassification, defaultFieldDataTypeClassification,
classifiers, classifiers,
fieldIdentifierTypes, fieldIdentifierTypes,
fieldIdentifierTypeIds,
fieldIdentifierTypeValues,
idLogicalTypes, idLogicalTypes,
customIdLogicalTypes, customIdLogicalTypes,
nonIdFieldLogicalTypes, nonIdFieldLogicalTypes,
@ -294,5 +345,6 @@ export {
hasPredefinedFieldFormat, hasPredefinedFieldFormat,
logicalTypesForIds, logicalTypesForIds,
logicalTypesForGeneric, logicalTypesForGeneric,
getDefaultLogicalType getDefaultLogicalType,
SuggestionIntent
}; };

View File

@ -35,3 +35,9 @@
visibility: hidden; visibility: hidden;
} }
} }
.compliance-entities-meta {
padding: item-spacing(2 0 2);
color: #777777;
text-align: left;
}

View File

@ -1,12 +1,13 @@
@import "../variables"; @import '../variables';
.dataset-compliance-fields { .dataset-compliance-fields {
&__has-suggestions { &__has-suggestions {
color: $compliance-suggestion-hint; color: $compliance-suggestion-hint;
margin-left: item-spacing(2);
} }
&__notification-column { &__notification-column {
width: 5% width: 5%;
} }
&__classification-column { &__classification-column {

View File

@ -1,5 +1,5 @@
@import "../../abstracts/variables"; @import '../../abstracts/variables';
@import "../../abstracts/mixins"; @import '../../abstracts/mixins';
/** /**
* <select> primary color, borders, icons, etcetera... * <select> primary color, borders, icons, etcetera...
@ -8,7 +8,7 @@ $color: set-color(grey, light);
$default-border: (1px solid shade($color, 20%)); $default-border: (1px solid shade($color, 20%));
.nacho-select { .nacho-select {
display: flex; display: inline-flex;
align-items: center; align-items: center;
height: 34px; height: 34px;
width: 100%; width: 100%;

View File

@ -1,4 +1,4 @@
@import "../variables"; @import '../variables';
/// Styles a dot that indicates that user attention is required /// Styles a dot that indicates that user attention is required
.notification-dot { .notification-dot {
@ -15,7 +15,7 @@
border-color: $compliance-suggestion-hint; border-color: $compliance-suggestion-hint;
} }
&--is-unsaved { &--needs-review {
border-color: set-color(blue, blue5); border-color: set-color(blue, blue5);
} }

View File

@ -7,10 +7,9 @@
fieldFormats=fieldFormats fieldFormats=fieldFormats
logicalType=logicalType logicalType=logicalType
classification=classification classification=classification
suggestionAuthority=suggestionAuthority
suggestionResolution=suggestionResolution suggestionResolution=suggestionResolution
suggestion=prediction suggestion=prediction
isNewField=isNewField isReviewRequested=isReviewRequested
onFieldIdentifierTypeChange=(action 'onFieldIdentifierTypeChange') onFieldIdentifierTypeChange=(action 'onFieldIdentifierTypeChange')
onFieldClassificationChange=(action 'onFieldClassificationChange') onFieldClassificationChange=(action 'onFieldClassificationChange')
onFieldLogicalTypeChange=(action 'onFieldLogicalTypeChange') onFieldLogicalTypeChange=(action 'onFieldLogicalTypeChange')

View File

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

View File

@ -20,30 +20,31 @@
{{/if}} {{/if}}
</section> </section>
{{#if mergedComplianceEntitiesAndColumnFields.length}} {{#if compliancePolicyChangeSet.length}}
<section class="compliance-entities-meta">
{{ember-selector
values=fieldReviewOptions
selected=(readonly fieldReviewOption)
selectionDidChange=(action "onFieldReviewChange")
}}
{{#if changeSetReviewCount}}
<span class="dataset-compliance-fields__has-suggestions">
{{changeSetReviewCount}} fields to be reviewed
</span>
{{/if}}
</section>
{{#dataset-table {{#dataset-table
class="nacho-table--stripped dataset-compliance-fields" class="nacho-table--stripped dataset-compliance-fields"
fields=mergedComplianceEntitiesAndColumnFields fields=filteredChangeSet
sortColumnWithName=sortColumnWithName sortColumnWithName=sortColumnWithName
filterBy=filterBy filterBy=filterBy
sortDirection=sortDirection sortDirection=sortDirection
tableRowComponent='dataset-compliance-row' tableRowComponent='dataset-compliance-row'
searchTerm=searchTerm as |table|}} searchTerm=searchTerm as |table|
}}
<caption>
<input
type="text"
title="Search fields"
placeholder="Search fields"
value="{{table.searchTerm}}"
oninput={{action table.filterDidChange value="target.value"}}>
{{#if (and hasRecentSuggestions (not isNewComplianceInfo))}}
<span class="dataset-compliance-fields__has-suggestions">
{{complianceSuggestion.complianceSuggestions.length}} fields to be reviewed
</span>
{{/if}}
</caption>
{{#table.head as |head|}} {{#table.head as |head|}}
{{#head.column class="dataset-compliance-fields__notification-column"}}{{/head.column}} {{#head.column class="dataset-compliance-fields__notification-column"}}{{/head.column}}
@ -94,6 +95,18 @@
{{/head.column}} {{/head.column}}
{{/table.head}} {{/table.head}}
<tr>
<th></th>
<th colspan="6">
{{disable-bubble-input
title="Search field names"
placeholder="Search field names"
value=table.searchTerm
on-input=(action table.filterDidChange value="target.value")
}}
</th>
</tr>
{{#table.body as |body|}} {{#table.body as |body|}}
{{#each (sort-by table.sortBy table.data) as |field|}} {{#each (sort-by table.sortBy table.data) as |field|}}
{{#body.row {{#body.row
@ -104,17 +117,22 @@
onFieldLogicalTypeChange=(action 'onFieldLogicalTypeChange') onFieldLogicalTypeChange=(action 'onFieldLogicalTypeChange')
onFieldClassificationChange=(action 'onFieldClassificationChange') onFieldClassificationChange=(action 'onFieldClassificationChange')
onSuggestionIntent=(action 'onFieldSuggestionIntentChange') onSuggestionIntent=(action 'onFieldSuggestionIntentChange')
onFieldIdentifierTypeChange=(action 'onFieldIdentifierTypeChange')as |row|}} onFieldIdentifierTypeChange=(action 'onFieldIdentifierTypeChange') as |row|
}}
{{#row.cell}} {{#row.cell}}
{{#if row.suggestion}} {{#if (and row.suggestion (not row.suggestionResolution))}}
<span class="notification-dot notification-dot--has-prediction" <span class="notification-dot notification-dot--has-prediction"
aria-label="Compliance fields have suggested values"></span> aria-label="Compliance fields have suggested values"></span>
{{/if}}
{{#if (and row.isNewField (not row.suggestion))}} {{else}}
<span class="notification-dot notification-dot--is-unsaved"
aria-label="Compliance has not been saved"></span> {{#if row.isReviewRequested}}
<span class="notification-dot notification-dot--needs-review"
aria-label="Compliance policy for field does not exist"></span>
{{/if}}
{{/if}} {{/if}}
{{/row.cell}} {{/row.cell}}
@ -134,8 +152,8 @@
{{auto-suggest-action action=(action row.onSuggestionAction)}} {{auto-suggest-action action=(action row.onSuggestionAction)}}
{{/if}} {{/if}}
{{else}} {{else}}
{{#if row.suggestionAuthority}} {{#if row.suggestionResolution}}
{{capitalize row.suggestionResolution}} {{row.suggestionResolution}}
{{else}} {{else}}
&mdash; &mdash;
{{/if}} {{/if}}

View File

@ -2,7 +2,7 @@ declare module 'ember-modal-dialog/components/modal-dialog';
declare module 'ember-simple-auth/mixins/authenticated-route-mixin'; declare module 'ember-simple-auth/mixins/authenticated-route-mixin';
declare module 'wherehows-web/utils/datasets/functions'; declare module 'wherehows-web/utils/datasets/compliance-policy';
// https://github.com/ember-cli/ember-fetch/issues/72 // https://github.com/ember-cli/ember-fetch/issues/72
// TS assumes the mapping btw ES modules and CJS modules is 1:1 // TS assumes the mapping btw ES modules and CJS modules is 1:1

View File

@ -6,7 +6,7 @@ import {
} from 'wherehows-web/typings/api/datasets/columns'; } from 'wherehows-web/typings/api/datasets/columns';
import { getJSON } from 'wherehows-web/utils/api/fetcher'; import { getJSON } from 'wherehows-web/utils/api/fetcher';
import { ApiStatus } from 'wherehows-web/utils/api'; import { ApiStatus } from 'wherehows-web/utils/api';
import { arrayMap } from 'wherehows-web/utils/array-map'; import { arrayMap } from 'wherehows-web/utils/array';
// TODO: DSS-6122 Create and move to Error module // TODO: DSS-6122 Create and move to Error module

View File

@ -1,5 +1,5 @@
import Ember from 'ember'; import Ember from 'ember';
import { createInitialComplianceInfo } from 'wherehows-web/utils/datasets/functions'; import { createInitialComplianceInfo } from 'wherehows-web/utils/datasets/compliance-policy';
import { datasetUrlById } from 'wherehows-web/utils/api/datasets/shared'; import { datasetUrlById } from 'wherehows-web/utils/api/datasets/shared';
import { ApiStatus } from 'wherehows-web/utils/api/shared'; import { ApiStatus } from 'wherehows-web/utils/api/shared';
import { IComplianceSuggestion, IComplianceSuggestionResponse } from 'wherehows-web/typings/api/datasets/compliance'; import { IComplianceSuggestion, IComplianceSuggestionResponse } from 'wherehows-web/typings/api/datasets/compliance';

View File

@ -1,10 +0,0 @@
/**
* Convenience utility takes a type-safe mapping function, and returns a list mapping function
* @param {(param: T) => U} mappingFunction maps a single type T to type U
* @return {(array: Array<T>) => Array<U>}
*/
const arrayMap = <T, U>(mappingFunction: (param: T) => U): ((array: Array<T>) => Array<U>) => {
return array => array.map(mappingFunction);
};
export { arrayMap };

View File

@ -0,0 +1,24 @@
/**
* Convenience utility takes a type-safe mapping function, and returns a list mapping function
* @param {(param: T) => U} mappingFunction maps a single type T to type U
* @return {(array: Array<T>) => Array<U>}
*/
const arrayMap = <T, U>(mappingFunction: (param: T) => U): ((array: Array<T>) => Array<U>) => (array = []) =>
array.map(mappingFunction);
/**
* Convenience utility takes a type-safe filter function, and returns a list filtering function
* @param {(param: T) => boolean} filtrationFunction
* @return {(array: Array<T>) => Array<T>}
*/
const arrayFilter = <T>(filtrationFunction: (param: T) => boolean): ((array: Array<T>) => Array<T>) => (array = []) =>
array.filter(filtrationFunction);
/**
* Duplicate check using every to short-circuit iteration
* @param {Array<T>} [list = []] list to check for dupes
* @return {boolean} true is unique
*/
const isListUnique = <T>(list: Array<T> = []): boolean => new Set(list).size === list.length;
export { arrayMap, arrayFilter, isListUnique };

View File

@ -110,4 +110,64 @@ const isPolicyExpectedShape = (candidatePolicy = {}) => {
return false; return false;
}; };
export { createInitialComplianceInfo, isPolicyExpectedShape }; /**
* Checks if a compliance policy changeSet field requires user attention: if a suggestion
* is available but the user has not indicated intent or a policy for the field does not currently exist remotely
* and the related field changeSet has not been modified on the client
* @param {boolean} isDirty flag indicating the field changeSet has been modified on the client
* @param {object|void} suggestion the field suggestion properties
* @param {boolean} privacyPolicyExists flag indicating that the field has a current policy upstream
* @param {string} suggestionAuthority possibly empty string indicating the user intent for the suggestion
* @return {boolean}
*/
const fieldChangeSetRequiresReview = ({ isDirty, suggestion, privacyPolicyExists, suggestionAuthority }) => {
if (suggestion) {
return !suggestionAuthority;
}
return !privacyPolicyExists && !isDirty;
};
/**
* Merges the column fields with the suggestion for the field if available
* @param {object} mappedColumnFields a map of column fields to compliance entity properties
* @param {object} fieldSuggestionMap a map of field suggestion properties keyed by field name
* @return {Array<object>} mapped column field augmented with suggestion if available
*/
const mergeMappedColumnFieldsWithSuggestions = (mappedColumnFields = {}, fieldSuggestionMap = {}) =>
Object.keys(mappedColumnFields).map(fieldName => {
const {
identifierField,
dataType,
identifierType,
logicalType,
securityClassification,
privacyPolicyExists,
isDirty
} = mappedColumnFields[fieldName];
const suggestion = fieldSuggestionMap[identifierField];
const field = {
identifierField,
dataType,
identifierType,
logicalType,
privacyPolicyExists,
isDirty,
classification: securityClassification
};
// If a suggestion exists for this field add the suggestion attribute to the field properties
if (suggestion) {
return { ...field, suggestion };
}
return field;
});
export {
createInitialComplianceInfo,
isPolicyExpectedShape,
fieldChangeSetRequiresReview,
mergeMappedColumnFieldsWithSuggestions
};

View File

@ -0,0 +1,15 @@
/**
* Checks if a type is an object
* @param {any} candidate the entity to check
*/
const isObject = (candidate: any): candidate is object =>
candidate && Object.prototype.toString.call(candidate) === '[object Object]';
/**
* Checks that an object has it own enumerable props
* @param {Object} object the object to the be tested
* @return {boolean} true if enumerable keys are present
*/
const hasEnumerableKeys = (object: object): boolean => isObject(object) && !!Object.keys(object).length;
export { isObject, hasEnumerableKeys };

View File

@ -42,7 +42,6 @@
"ember-cli-typescript": "^1.0.3", "ember-cli-typescript": "^1.0.3",
"ember-cli-uglify": "^1.2.0", "ember-cli-uglify": "^1.2.0",
"ember-composable-helpers": "^2.0.0", "ember-composable-helpers": "^2.0.0",
"ember-data": "^2.11.1",
"ember-export-application-global": "^1.1.1", "ember-export-application-global": "^1.1.1",
"ember-fetch": "^3.4.0", "ember-fetch": "^3.4.0",
"ember-lodash-shim": "^2.0.2", "ember-lodash-shim": "^2.0.2",