diff --git a/wherehows-web/app/components/dataset-compliance.js b/wherehows-web/app/components/dataset-compliance.js index 2e07cddc1f..c3f72f77c5 100644 --- a/wherehows-web/app/components/dataset-compliance.js +++ b/wherehows-web/app/components/dataset-compliance.js @@ -10,126 +10,203 @@ const { } = Ember; const complianceListKey = 'privacyCompliancePolicy.compliancePurgeEntities'; -// List of field formats for security compliance -const fieldFormats = [ - 'MEMBER_ID', 'SUBJECT_MEMBER_ID', 'URN', 'SUBJECT_URN', 'COMPANY_ID', 'GROUP_ID', 'CUSTOMER_ID' -]; +const logicalTypes = ['id', 'number', 'urn', 'reversed urn', 'hash'].sort(); /** - * Computed macro tha checks if a dependentList has a specified privacy type - * @param {String} dependentListKey - * @param {String} type - * @returns {boolean} + * Returns a computed macro based on a provided type will return a list of + * Compliance fields that are of that identifierType or have no type + * @param {String} type string to match against identifierType */ -const datasetHasPrivacyIdentifierType = (dependentListKey, type) => { - const prop = 'identifierType'; +const complianceEntitiesMatchingType = type => { + return computed('complianceDataFields.[]', function() { + const fieldRegex = new RegExp(`${type}`, 'i'); - return computed(`${dependentListKey}.@each.${prop}`, { - get() { - const list = get(this, dependentListKey) || []; - return list.filterBy(prop, type).length > 0; - } + return get(this, 'complianceDataFields').filter(({ identifierType }) => { + return fieldRegex.test(identifierType) || isBlank(identifierType); + }); }); }; export default Component.extend({ sortColumnWithName: 'name', - filterBy: 'name', + filterBy: 'identifierField', sortDirection: 'asc', searchTerm: '', - radioSelection: { - memberId: null, - subjectMemberId: null, - urnId: null, - orgId: null + /** + * Map of radio Group state values + * Each initially has an indeterminate state, as the user + * progresses through the prompts + * @type {Object.} + */ + userIndicatesDatasetHas: { + member: null, + org: null, + group: null }, - hasMemberId: datasetHasPrivacyIdentifierType(complianceListKey, 'MEMBER_ID'), - hasSubjectMemberId: datasetHasPrivacyIdentifierType(complianceListKey, 'SUBJECT_MEMBER_ID'), - hasUrnId: datasetHasPrivacyIdentifierType(complianceListKey, 'URN'), - hasOrgId: datasetHasPrivacyIdentifierType(complianceListKey, 'COMPANY_ID'), + // Component ui state transitions based on the userIndicatesDatasetHas map + hasUserRespondedToMemberPrompt: computed.notEmpty( + 'userIndicatesDatasetHas.member' + ), + showOrgPrompt: computed.bool('hasUserRespondedToMemberPrompt'), + hasUserRespondedToOrgPrompt: computed.notEmpty('userIndicatesDatasetHas.org'), + showGroupPrompt: computed.bool('hasUserRespondedToOrgPrompt'), - isMemberIdSelected: computed.notEmpty('radioSelection.memberId'), - isMemberIdSelectedTrue: computed.equal('radioSelection.memberId', true), - isSubjectMemberIdSelected: computed.notEmpty('radioSelection.subjectMemberId'), - isUrnIdSelected: computed.notEmpty('radioSelection.urnId'), - - showSubjectMemberIdPrompt: computed.equal('radioSelection.memberId', false), - showUrnIdPrompt: computed.or('isSubjectMemberIdSelected', 'isMemberIdSelectedTrue'), - showOrgIdPrompt: computed.bool('isUrnIdSelected'), - - identifierTypes: ['', ...fieldFormats].map(type => ({ - value: type, - label: type ? type.replace(/_/g, ' ').toLowerCase().capitalize() : 'Please select' + // Map logicalTypes to options consumable by ui + logicalTypes: ['', ...logicalTypes].map(value => ({ + value, + label: value ? value.capitalize() : 'Please select' })), - complianceEntities: computed(`${complianceListKey}.[]`, function () { - return getWithDefault(this, complianceListKey, []); - }), + /** + * Lists all dataset fields found in the `columns` performs an intersection + * of fields with the currently persisted and/or updated + * privacyCompliancePolicy.compliancePurgeEntities. + * The returned list is a map of fields with current or default privacy properties + */ + complianceDataFields: computed( + `${complianceListKey}.@each.identifierType`, + `${complianceListKey}.[]`, + 'schemaFieldNamesMappedToDataTypes', + function() { + const sourceEntities = getWithDefault(this, complianceListKey, []); + const complianceFieldNames = sourceEntities.mapBy('identifierField'); - ownerFieldIdType(field) { - const ownerField = getWithDefault(this, complianceListKey, []).filterBy('identifierField', field).shift(); - return ownerField && ownerField.identifierType; - }, + const getAttributeOnField = (attribute, fieldName) => { + const sourceField = getWithDefault(this, complianceListKey, []).find( + ({ identifierField }) => identifierField === fieldName + ); + return sourceField ? sourceField[attribute] : null; + }; - complianceFields: computed('complianceEntities', 'datasetSchemaFieldsAndTypes', function () { - const complianceFieldNames = get(this, 'complianceEntities').mapBy('identifierField'); + // Set default or if already in policy, retrieve current values from + // privacyCompliancePolicy.compliancePurgeEntities + return getWithDefault(this, 'schemaFieldNamesMappedToDataTypes', [ + ]).map(({ fieldName, dataType }) => ({ + dataType, + identifierField: fieldName, + identifierType: getAttributeOnField('identifierType', fieldName), + hasPrivacyData: complianceFieldNames.includes(fieldName), + isSubject: getAttributeOnField('isSubject', fieldName), + logicalType: getAttributeOnField('logicalType', fieldName) + })); + } + ), - return get(this, 'datasetSchemaFieldsAndTypes').map(({name, type}) => ({ - name, - type, - hasPrivacyData: complianceFieldNames.includes(name), - format: get(this, 'ownerFieldIdType').call(this, name) - })); - }), + // Compliance entities filtered for each identifierType + memberComplianceEntities: complianceEntitiesMatchingType('member'), + orgComplianceEntities: complianceEntitiesMatchingType('organization'), + groupComplianceEntities: complianceEntitiesMatchingType('group'), - changeFieldFormat(fieldName, format) { - let field = get(this, 'complianceEntities').findBy('identifierField', fieldName); + /** + * Changes the logicalType on a field. + * Ensures that the logicalType / format is applicable to the specified field + * @param {String} fieldName the fieldName identifying the field to be updated + * @param {String} format logicalType or format te field is in + * @return {String| void} + */ + changeFieldLogicalType(fieldName, format) { + const sourceField = get(this, complianceListKey).findBy( + 'identifierField', + fieldName + ); - if (field && fieldFormats.includes(format)) { - return set(field, 'identifierType', format); + if (sourceField && logicalTypes.includes(format)) { + return set(sourceField, 'logicalType', String(format).toUpperCase()); } }, - toggleFieldOnComplianceList(identifierField, toggle) { - const complianceList = get(this, 'complianceEntities'); - const op = { + /** + * Adds or removes a field onto the + * privacyCompliancePolicy.compliancePurgeEntities list. + * @param {Object} props initial props for the field to be added + * @prop {String} field.identifierField + * @param {String} identifierType the type of the field to toggle + * @param {('add'|'remove')} toggle operation to perform, can either be + * add or remove + * @return {Ember.Array|*} + */ + toggleFieldOnComplianceList(props, identifierType, toggle) { + const identifierField = get(props, 'identifierField'); + const sourceEntities = get(this, complianceListKey); + + if (!['add', 'remove'].includes(toggle)) { + throw new Error(`Unsupported toggle operation ${toggle}`); + } + + return { add() { - if (!complianceList.findBy('identifierField', identifierField)) { - return complianceList.setObjects([...complianceList, {identifierField}]); + // Ensure that we don't currently have this field present on the + // privacyCompliancePolicy.compliancePurgeEntities list + if (!sourceEntities.findBy('identifierField', identifierField)) { + const addPurgeEntity = { identifierField, identifierType }; + + return sourceEntities.setObjects([addPurgeEntity, ...sourceEntities]); } }, - remove: () => complianceList.setObjects(complianceList.filter(item => item.identifierField !== identifierField)), + remove: () => { + // Remove the identifierType since we are removing it from the + // privacyCompliancePolicy.compliancePurgeEntities in case it + // is added back during the session + set(props, 'identifierType', null); + return sourceEntities.setObjects( + sourceEntities.filter( + item => item.identifierField !== identifierField + ) + ); + } }[toggle]; - - return typeof op === 'function' && op(); }, ensureTypeContainsFormat: (updatedCompliance) => updatedCompliance.every(entity => fieldFormats.includes(get(entity, 'identifierType'))), actions: { - onFieldFormatChange({identifierField: fieldName}, {value: format}) { - return this.changeFieldLogicalType(fieldName, format); + /** + * + * @param {String} identifierField id for the field to update + * @param {String} logicalType updated format to apply to the field + * @return {*|String|void} logicalType or void + */ + onFieldFormatChange({ identifierField }, { value: logicalType }) { + return this.changeFieldLogicalType(identifierField, logicalType); }, - onFieldPrivacyChange({identifierField: fieldName, hasPrivacyData}) { + /** + * Toggles a field on / off the compliance list + * @param {String} identifierType the type of the field to be toggled on + * the privacyCompliancePolicy.compliancePurgeEntities list + * @param {Object|Ember.Object} props containing the props to be added + * @prop {Boolean} field.hasPrivacyData checked or not checked + * @return {*} + */ + onFieldPrivacyChange(identifierType, props) { + // If checked, add, otherwise remove + const { hasPrivacyData } = props; const toggle = !hasPrivacyData ? 'add' : 'remove'; - return this.toggleFieldOnComplianceList(fieldName, toggle); + return this.toggleFieldOnComplianceList(props, identifierType, toggle); }, /** * Toggles the isSubject property of a member identifiable field - * @param {Object} field + * @param {Object} props the props on the member field to update + * @prop {Boolean} isSubject flag indicating this field as a subject owner + * when true + * @prop {String} identifierField unique field to update isSubject property */ - onMemberFieldSubjectChange(field) { - const {isSubject} = field; + onMemberFieldSubjectChange(props) { + const { isSubject, identifierField: name } = props; - if (field && 'isSubject' in field) { - set(field, 'isSubject', !isSubject); + // Ensure that a flag isSubject is present on the props + if (props && 'isSubject' in props) { + const sourceField = get(this, complianceListKey).find( + ({ identifierField }) => identifierField === name + ); + + set(sourceField, 'isSubject', !isSubject); } }, @@ -143,19 +220,8 @@ export default Component.extend({ // Rolls back changes made to the compliance spec to current // server state - resetCompliance () { + resetCompliance() { this.get('onReset')(); - }, - - didChangePrivacyIdentifiable (sectionName, isPrivacyIdentifiable) { - const section = { - 'has-subject-member': 'subjectMemberId', - 'has-urn': 'urnId', - 'has-organization': 'orgId', - 'has-member': 'memberId' - }[sectionName]; - - return set(this, `radioSelection.${section}`, isPrivacyIdentifiable); } } }); diff --git a/wherehows-web/app/styles/abstracts/_functions.scss b/wherehows-web/app/styles/abstracts/_functions.scss index d5b894368f..85b54b7fcf 100644 --- a/wherehows-web/app/styles/abstracts/_functions.scss +++ b/wherehows-web/app/styles/abstracts/_functions.scss @@ -41,6 +41,20 @@ @return null; } +/// Get a font-weight from the $font-weight map +/// @param {String} $style - the font-style to look up +/// @param {Number} $weight - number for the previous style to be looked up +/// @return {Number} +/// @require $font-weights +@function fw($style, $weight) { + @if map-has-key($font-weights, $style) { + @return map-get(map-get($font-weights, $style), $weight); + } + + @warn '#{$style} not found in `$font-weights` list'; + @return null; +} + /// Progressively add black as you decrease the proportion of the color /// Provides more subtle transitions than darken /// @param {Color} $color - color to shade diff --git a/wherehows-web/app/styles/abstracts/_variables.scss b/wherehows-web/app/styles/abstracts/_variables.scss index a80542a83f..f70b4b31c3 100644 --- a/wherehows-web/app/styles/abstracts/_variables.scss +++ b/wherehows-web/app/styles/abstracts/_variables.scss @@ -16,6 +16,21 @@ $font-awesome-stack: ( sans-serif ); +$font-weights: ( + normal:( + 2: 200, + 3: 300, + 4: 400, + 6:600 + ), + italic:( + 2: 200, + 3:300, + 4:400, + 6:600 + ) +); + /// Color Scheme /// @type Map /// @prop {String} key - Scheme name diff --git a/wherehows-web/app/styles/components/_all.scss b/wherehows-web/app/styles/components/_all.scss index f85caa9c2e..6d4ba6fb30 100644 --- a/wherehows-web/app/styles/components/_all.scss +++ b/wherehows-web/app/styles/components/_all.scss @@ -2,6 +2,7 @@ @import "navbar"; @import "hero"; @import "dataset-author/all"; +@import "dataset-compliance/all"; @import "nacho/nacho-button"; @import "nacho/nacho-global-search"; diff --git a/wherehows-web/app/styles/components/_navbar.scss b/wherehows-web/app/styles/components/_navbar.scss index 004eaac7b5..bb19cd6442 100644 --- a/wherehows-web/app/styles/components/_navbar.scss +++ b/wherehows-web/app/styles/components/_navbar.scss @@ -39,4 +39,8 @@ } } } -} \ No newline at end of file +} + +.action-bar { + margin-bottom: 20px; +}