Merge pull request #600 from theseyi/dupes

fixes issue with dupes in saved compliance entities list: ensures tha…
This commit is contained in:
Seyi Adebajo 2017-07-19 08:21:01 -07:00 committed by GitHub
commit ee049d565b
5 changed files with 163 additions and 69 deletions

View File

@ -6,7 +6,8 @@ import {
fieldIdentifierTypes, fieldIdentifierTypes,
idLogicalTypes, idLogicalTypes,
nonIdFieldLogicalTypes, nonIdFieldLogicalTypes,
defaultFieldDataTypeClassification defaultFieldDataTypeClassification,
compliancePolicyStrings
} from 'wherehows-web/constants'; } from 'wherehows-web/constants';
import { isPolicyExpectedShape } from 'wherehows-web/utils/datasets/functions'; import { isPolicyExpectedShape } from 'wherehows-web/utils/datasets/functions';
@ -23,25 +24,14 @@ const {
String: { htmlSafe } String: { htmlSafe }
} = Ember; } = Ember;
// TODO: DSS-6671 Extract to constants module const { complianceDataException, missingTypes, successUpdating, failedUpdating, helpText } = compliancePolicyStrings;
const missingTypes = 'Looks like some fields may contain privacy data but do not have a specified `Field Format`?';
const successUpdating = 'Your changes have been successfully saved!';
const failedUpdating = 'Oops! We are having trouble updating this dataset at the moment.';
const hiddenTrackingFieldsMsg = htmlSafe( const hiddenTrackingFieldsMsg = htmlSafe(
'<p>Some fields in this dataset have been hidden from the table(s) below. ' + '<p>Some fields in this dataset have been hidden from the table(s) below. ' +
"These are tracking fields for which we've been able to predetermine the compliance classification.</p>" + "These are tracking fields for which we've been able to predetermine the compliance classification.</p>" +
'<p>For example: <code>header.memberId</code>, <code>requestHeader</code>. ' + '<p>For example: <code>header.memberId</code>, <code>requestHeader</code>. ' +
'Hopefully, this saves you some scrolling!</p>' 'Hopefully, this saves you some scrolling!</p>'
); );
const helpText = {
classification:
'The default value is taken from go/dht and should be good enough in most cases. ' +
'You can optionally override it if required by house security.',
subjectOwner:
'Member ID (Subject Owner): Choose this option if the member specified here is not the owner of this ' +
'record, e.g. the profile ID viewed, or recipient ID of a message. See wiki for more explanation. ' +
'Mixed ID: Choose this option if the field is a URN containing a mixture of member, organization, or group ID.'
};
/** /**
* Takes a string, returns a formatted string. Niche , single use case * Takes a string, returns a formatted string. Niche , single use case
@ -85,10 +75,10 @@ const policyFieldClassificationKey = 'complianceInfo.fieldClassification';
const policyComplianceEntitiesKey = 'complianceInfo.complianceEntities'; const policyComplianceEntitiesKey = 'complianceInfo.complianceEntities';
/** /**
* Duplicate check using every to short-circuit iteration * Duplicate check using every to short-circuit iteration
* @param {Array} names = [] the list to check for dupes * @param {Array} list = [] the list to check for dupes
* @return {Boolean} true is unique, false otherwise * @return {Boolean} true is unique, false otherwise
*/ */
const fieldNamesAreUnique = (names = []) => names.every((name, index) => names.indexOf(name) === index); 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');
@ -146,8 +136,17 @@ export default Component.extend({
fieldIdentifierOptions, fieldIdentifierOptions,
hiddenTrackingFields: hiddenTrackingFieldsMsg, hiddenTrackingFields: hiddenTrackingFieldsMsg,
classNames: ['compliance-container'], classNames: ['compliance-container'],
isEditing: false,
classNameBindings: ['isEditing:compliance-container--edit-mode'], classNameBindings: ['isEditing:compliance-container--edit-mode'],
/**
* Flag indicating that the component is in edit mode
* @type {String}
*/
isEditing: false,
/**
* Flag indicating that the component is currently saving / attempting to save the privacy policy
* @type {String}
*/
isSaving: false,
didReceiveAttrs() { didReceiveAttrs() {
this._super(...arguments); this._super(...arguments);
@ -164,7 +163,8 @@ export default Component.extend({
validateAttrs() { validateAttrs() {
const fieldNames = getWithDefault(this, 'schemaFieldNamesMappedToDataTypes', []).mapBy('fieldName'); const fieldNames = getWithDefault(this, 'schemaFieldNamesMappedToDataTypes', []).mapBy('fieldName');
if (fieldNamesAreUnique(fieldNames.sort())) { // identifier field names from the column api should be unique
if (listIsUnique(fieldNames.sort())) {
return set(this, '_hasBadData', false); return set(this, '_hasBadData', false);
} }
@ -196,18 +196,20 @@ export default Component.extend({
* tracking header. * tracking header.
* Used to indicate to viewer that these fields are hidden. * Used to indicate to viewer that these fields are hidden.
*/ */
containsHiddenTrackingFields: computed('complianceDataFieldsSansHiddenTracking.length', function() { containsHiddenTrackingFields: computed('truncatedSchemaFields.length', function() {
// If their is a diff in complianceDataFields and complianceDataFieldsSansHiddenTracking, // If their is a diff in complianceDataFields and truncatedSchemaFields,
// then we have hidden tracking fields // then we have hidden tracking fields
return get(this, 'complianceDataFieldsSansHiddenTracking.length') !== get(this, 'complianceDataFields.length'); return get(this, 'truncatedSchemaFields.length') !== get(this, 'complianceDataFields.length');
}), }),
/** /**
* @type {Array.<Object>} Filters the mapped compliance data fields without `kafka type` * @type {Array.<Object>} Filters the mapped compliance data fields without `kafka type`
* tracking headers * tracking headers
*/ */
complianceDataFieldsSansHiddenTracking: computed('complianceDataFields.[]', function() { truncatedSchemaFields: computed('schemaFieldNamesMappedToDataTypes', function() {
return get(this, 'complianceDataFields').filter(({ identifierField }) => !isTrackingHeaderField(identifierField)); return getWithDefault(this, 'schemaFieldNamesMappedToDataTypes', []).filter(
({ fieldName }) => !isTrackingHeaderField(fieldName)
);
}), }),
/** /**
@ -222,6 +224,28 @@ export default Component.extend({
.every(({ value }) => [true, false].includes(value)); .every(({ value }) => [true, false].includes(value));
}), }),
/**
* Determines if the save feature is allowed for the current dataset, otherwise e.g. interface should be disabled
* @type {Ember.computed}
*/
isSavingDisabled: computed('isDatasetFullyClassified', 'isSaving', function() {
const { isDatasetFullyClassified, isSaving } = getProperties(this, ['isDatasetFullyClassified', 'isSaving']);
return !isDatasetFullyClassified || isSaving;
}),
/**
* Checks to ensure the the number of fields added to compliance entities is less than or equal
* to what is available on the dataset schema
* @return {boolean}
*/
isSchemaFieldLengthGreaterThanComplianceEntities() {
const { length: columnFieldsLength } = getWithDefault(this, 'schemaFieldNamesMappedToDataTypes', []);
const { length: complianceListLength } = get(this, policyComplianceEntitiesKey);
return columnFieldsLength >= complianceListLength;
},
/** /**
* Computed property that is dependent on all the keys in the datasetClassification map * Computed property that is dependent on all the keys in the datasetClassification map
* Returns a new map of datasetClassificationKey: String-> Object.<Boolean|undefined,String> * Returns a new map of datasetClassificationKey: String-> Object.<Boolean|undefined,String>
@ -250,7 +274,7 @@ export default Component.extend({
`${policyComplianceEntitiesKey}.@each.identifierType`, `${policyComplianceEntitiesKey}.@each.identifierType`,
`${policyComplianceEntitiesKey}.[]`, `${policyComplianceEntitiesKey}.[]`,
policyFieldClassificationKey, policyFieldClassificationKey,
'schemaFieldNamesMappedToDataTypes', 'truncatedSchemaFields',
function() { function() {
/** /**
* Retrieves an attribute on the `policyComplianceEntitiesKey` where the identifierField is the same as the * Retrieves an attribute on the `policyComplianceEntitiesKey` where the identifierField is the same as the
@ -261,7 +285,17 @@ export default Component.extend({
*/ */
const getAttributeOnField = (attribute, fieldName) => { const getAttributeOnField = (attribute, fieldName) => {
const complianceEntities = get(this, policyComplianceEntitiesKey) || []; const complianceEntities = get(this, policyComplianceEntitiesKey) || [];
const sourceField = complianceEntities.find(({ identifierField }) => identifierField === fieldName); // const sourceField = complianceEntities.find(({ identifierField }) => identifierField === fieldName);
let sourceField;
// For long records: >500 elements, the find operation is consistently less performant than a for-loop:
// trading elegance for efficiency here
for (let i = 0; i < complianceEntities.length; i++) {
if (complianceEntities[i]['identifierField'] === fieldName) {
sourceField = complianceEntities[i];
break;
}
}
return sourceField ? sourceField[attribute] : null; return sourceField ? sourceField[attribute] : null;
}; };
@ -277,11 +311,7 @@ export default Component.extend({
// Set default or if already in policy, retrieve current values from // Set default or if already in policy, retrieve current values from
// privacyCompliancePolicy.complianceEntities // privacyCompliancePolicy.complianceEntities
return getWithDefault( return getWithDefault(this, 'truncatedSchemaFields', []).map(({ fieldName: identifierField, dataType }) => {
this,
'schemaFieldNamesMappedToDataTypes',
[]
).map(({ fieldName: identifierField, dataType }) => {
const [identifierType, isSubject, logicalType] = getAttributesOnField( const [identifierType, isSubject, logicalType] = getAttributesOnField(
['identifierType', 'isSubject', 'logicalType'], ['identifierType', 'isSubject', 'logicalType'],
identifierField identifierField
@ -372,7 +402,7 @@ export default Component.extend({
* @param { Boolean} [isSaving = false] optional flag indicating when the user intends to persist / save * @param { Boolean} [isSaving = false] optional flag indicating when the user intends to persist / save
*/ */
whenRequestCompletes(request, { successMessage, isSaving = false } = {}) { whenRequestCompletes(request, { successMessage, isSaving = false } = {}) {
Promise.resolve(request) return Promise.resolve(request)
.then(({ status = 'error' }) => { .then(({ status = 'error' }) => {
return status === 'ok' return status === 'ok'
? setProperties(this, { ? setProperties(this, {
@ -421,10 +451,11 @@ export default Component.extend({
*/ */
confirmUnformattedFields() { confirmUnformattedFields() {
// Current list of compliance entities on policy // Current list of compliance entities on policy
const complianceList = get(this, policyComplianceEntitiesKey); const complianceEntities = get(this, policyComplianceEntitiesKey);
// All candidate fields that can be on policy // All candidate fields that can be on policy, excluding tracking type fields
const datasetFields = get(this, 'complianceDataFieldsSansHiddenTracking'); const datasetFields = get(this, 'complianceDataFields');
// Fields that do not have a logicalType and not identifierType or identifierType is `fieldIdentifierTypes.none` const fieldsCurrentlyInComplianceList = complianceEntities.mapBy('identifierField');
// Fields that do not have a logicalType, and no identifierType or identifierType is `fieldIdentifierTypes.none`
const unformattedFields = datasetFields.filter( const unformattedFields = datasetFields.filter(
({ identifierType, logicalType }) => ({ identifierType, logicalType }) =>
!logicalType && (fieldIdentifierTypes.none.value === identifierType || !identifierType) !logicalType && (fieldIdentifierTypes.none.value === identifierType || !identifierType)
@ -433,6 +464,17 @@ export default Component.extend({
// If there are unformatted fields, require confirmation from user // If there are unformatted fields, require confirmation from user
if (!isEmpty(unformattedFields)) { if (!isEmpty(unformattedFields)) {
// Ensure that the unformatted fields to be added to the entities are not already present
// in the previous compliance entities list
const unformattedComplianceEntities = unformattedFields
.filter(({ identifierField }) => !fieldsCurrentlyInComplianceList.includes(identifierField))
.map(({ identifierField }) => ({
identifierField,
identifierType: fieldIdentifierTypes.none.value,
isSubject: null,
logicalType: void 0
}));
isConfirmed = confirm( isConfirmed = confirm(
`There are ${unformattedFields.length} non-ID fields that have no field format specified. ` + `There are ${unformattedFields.length} non-ID fields that have no field format specified. ` +
`Are you sure they don't contain any of the following PII?\n\n` + `Are you sure they don't contain any of the following PII?\n\n` +
@ -441,21 +483,43 @@ export default Component.extend({
// If the user confirms that this is ok, apply the unformatted fields on the current compliance list // If the user confirms that this is ok, apply the unformatted fields on the current compliance list
// to be saved // to be saved
isConfirmed && isConfirmed && complianceEntities.setObjects([...complianceEntities, ...unformattedComplianceEntities]);
complianceList.setObjects([
...complianceList,
...unformattedFields.map(({ identifierField }) => ({
identifierField,
identifierType: fieldIdentifierTypes.none.value,
isSubject: null,
logicalType: void 0
}))
]);
} }
return isConfirmed; return isConfirmed;
}, },
/**
* Ensures the fields in the updated list of compliance entities meet the criteria
* checked in the function. If criteria is not met, an the returned promise is settled
* in a rejected state, otherwise fulfilled
* @method
* @return {any | Promise<any>}
*/
validateFields() {
const complianceEntities = get(this, policyComplianceEntitiesKey);
const idFieldsHaveValidLogicalType = this.checkEachEntityByLogicalType(
complianceEntities.filter(({ identifierType }) => fieldIdentifierTypeIds.includes(identifierType)),
[...genericLogicalTypes, ...idLogicalTypes]
);
const fieldIdentifiersAreUnique = listIsUnique(complianceEntities.mapBy('identifierField'));
const schemaFieldLengthGreaterThanComplianceEntities = this.isSchemaFieldLengthGreaterThanComplianceEntities();
//
if (!fieldIdentifiersAreUnique || !schemaFieldLengthGreaterThanComplianceEntities) {
return Promise.reject(new Error(complianceDataException));
}
if (!idFieldsHaveValidLogicalType) {
return Promise.reject(
setProperties(this, {
_message: missingTypes,
_alertType: 'danger'
})
);
}
},
actions: { actions: {
/** /**
* Handle the user intent to place this compliance component in edit mode * Handle the user intent to place this compliance component in edit mode
@ -634,26 +698,31 @@ export default Component.extend({
/** /**
* If all validity checks are passed, invoke onSave action on controller * If all validity checks are passed, invoke onSave action on controller
*/ */
saveCompliance() { async saveCompliance() {
const complianceList = get(this, policyComplianceEntitiesKey); const setSaveFlag = (flag = false) => set(this, 'isSaving', flag);
const idFieldsHaveValidLogicalType = this.checkEachEntityByLogicalType( // If fields are confirmed as unique we can proceed with saving compliance entities
complianceList.filter(({ identifierType }) => fieldIdentifierTypeIds.includes(identifierType)),
[...genericLogicalTypes, ...idLogicalTypes]
);
const saveConfirmed = this.confirmUnformattedFields(); const saveConfirmed = this.confirmUnformattedFields();
// If user provides confirmation for unformatted fields or there are none, // If user provides confirmation for unformatted fields or there are none,
// then check if id type fields have a specified logical type // then validate fields against expectations
// otherwise we should inform the user of missing field formats for id fields, which are not allowed // otherwise inform user of validation exception
if (saveConfirmed) { if (saveConfirmed) {
if (idFieldsHaveValidLogicalType) { try {
return this.whenRequestCompletes(get(this, 'onSave')(), { isSaving: true }); const isSaving = true;
} const onSave = get(this, 'onSave');
setSaveFlag(isSaving);
await this.validateFields();
setProperties(this, { return await this.whenRequestCompletes(onSave(), { isSaving });
_message: missingTypes, } catch (e) {
_alertType: 'danger' // Flag this dataset's data as problematic
}); if (e instanceof Error && e.message === complianceDataException) {
set(this, '_hasBadData', true);
window.scrollTo(0, 0);
}
} finally {
setSaveFlag();
}
} }
}, },

View File

@ -0,0 +1,17 @@
/**
* Defines a map of values for the compliance policy on a dataset
* @type {Object}
*/
const compliancePolicyStrings = {
complianceDataException: 'Unexpected discrepancy in compliance data',
missingTypes: 'Looks like some fields may contain privacy data but do not have a specified `Field Format`?',
successUpdating: 'Your changes have been successfully saved!',
failedUpdating: 'Oops! We are having trouble updating this dataset at the moment.',
helpText: {
classification:
'The default value is taken from go/dht and should be good enough in most cases. ' +
'You can optionally override it if required by house security.'
}
};
export { compliancePolicyStrings };

View File

@ -1,2 +1,3 @@
export * from 'wherehows-web/constants/metadata-acquisition'; export * from 'wherehows-web/constants/metadata-acquisition';
export * from 'wherehows-web/constants/dataset-classification'; export * from 'wherehows-web/constants/dataset-classification';
export * from 'wherehows-web/constants/dataset-compliance';

View File

@ -1,7 +1,11 @@
.post-action-notification { .post-action-notification {
margin-bottom: 0; margin-bottom: 0;
display: inline-block;
border-radius: 2px; border-radius: 2px;
padding: 5px; padding: 5px;
vertical-align: top; vertical-align: top;
} }
/// Defines styles for a notification bar in an action bar
.action-footer-notification {
display: inline-block;
}

View File

@ -15,11 +15,12 @@
<section class="action-bar"> <section class="action-bar">
{{#if isEditing}} {{#if isEditing}}
<div class="container action-bar__content"> <div class="container action-bar__content">
<button class="nacho-button nacho-button--large-inverse action-bar__item" <button
class="nacho-button nacho-button--large-inverse action-bar__item"
title={{unless isDatasetFullyClassified title={{unless isDatasetFullyClassified
"Ensure you have provided a yes/no value for all dataset tags" "Ensure you have provided a yes/no value for all dataset tags"
"Save"}} "Save"}}
disabled={{not isDatasetFullyClassified}} disabled={{isSavingDisabled}}
{{action "saveCompliance"}}> {{action "saveCompliance"}}>
Save Save
</button> </button>
@ -32,7 +33,7 @@
</button> </button>
{{#if _message}} {{#if _message}}
<div class="alert alert-{{_alertType}} post-action-notification" role="alert"> <div class="alert alert-{{_alertType}} post-action-notification action-footer-notification" role="alert">
{{_message}} {{_message}}
</div> </div>
{{/if}} {{/if}}
@ -51,7 +52,9 @@
</div> </div>
{{#if isEditing}} {{#if isEditing}}
{{#if (not _hasBadData)}}
{{json-upload receiveJsonFile=(action "onComplianceJsonUpload") class="secondary-actions__action"}} {{json-upload receiveJsonFile=(action "onComplianceJsonUpload") class="secondary-actions__action"}}
{{/if}}
{{else}} {{else}}
<button <button
{{action "onEdit"}} {{action "onEdit"}}
@ -165,7 +168,7 @@
{{#dataset-table {{#dataset-table
class="nacho-table--stripped" class="nacho-table--stripped"
fields=complianceDataFieldsSansHiddenTracking fields=complianceDataFields
sortColumnWithName=sortColumnWithName sortColumnWithName=sortColumnWithName
filterBy=filterBy filterBy=filterBy
sortDirection=sortDirection sortDirection=sortDirection