diff --git a/wherehows-web/.eslintrc.js b/wherehows-web/.eslintrc.js index 3958d052af..85c7fe7d0d 100644 --- a/wherehows-web/.eslintrc.js +++ b/wherehows-web/.eslintrc.js @@ -2,7 +2,10 @@ module.exports = { "extends": "eslint:recommended", "parserOptions": { "ecmaVersion": 8, - "sourceType": "module" + "sourceType": "module", + "ecmaFeatures": { + "experimentalObjectRestSpread": true + } }, "env": { "browser": true, diff --git a/wherehows-web/.jshintrc b/wherehows-web/.jshintrc deleted file mode 100644 index c464a25fea..0000000000 --- a/wherehows-web/.jshintrc +++ /dev/null @@ -1,32 +0,0 @@ -{ - "predef": [ - "document", - "window", - "Promise" - ], - "browser": true, - "boss": true, - "curly": true, - "debug": false, - "devel": true, - "eqeqeq": true, - "evil": true, - "forin": false, - "immed": false, - "laxbreak": false, - "newcap": true, - "noarg": true, - "noempty": false, - "nonew": false, - "nomen": false, - "onevar": false, - "plusplus": false, - "regexp": false, - "undef": true, - "sub": true, - "strict": false, - "white": false, - "eqnull": true, - "esversion": 6, - "unused": true -} diff --git a/wherehows-web/app/components/dataset-compliance-row.js b/wherehows-web/app/components/dataset-compliance-row.js new file mode 100644 index 0000000000..42744519c2 --- /dev/null +++ b/wherehows-web/app/components/dataset-compliance-row.js @@ -0,0 +1,153 @@ +import Ember from 'ember'; +import DatasetTableRow from 'wherehows-web/components/dataset-table-row'; +import { fieldIdentifierTypes, defaultFieldDataTypeClassification } from 'wherehows-web/constants'; +import { + fieldIdentifierTypeIds, + logicalTypesForIds, + logicalTypesForGeneric +} from 'wherehows-web/components/dataset-compliance'; + +const { computed, get } = Ember; + +const isMixedId = identifierType => identifierType === fieldIdentifierTypes.generic.value; +const isCustomId = identifierType => identifierType === fieldIdentifierTypes.custom.value; + +export default DatasetTableRow.extend({ + /** + * @type {Array} logical id types mapped to options for + */ + logicalTypesForGeneric, + + /** + * flag indicating that the identifier type is a generic value + * @type {Ember.computed} + */ + isMixed: computed.equal('field.identifierType', fieldIdentifierTypes.generic.value), + + /** + * flag indicating that the identifier type is a custom value + * @type {Ember.computed} + */ + isCustom: computed.equal('field.identifierType', fieldIdentifierTypes.custom.value), + + /** + * aliases the identifierField on the field + * @type {Ember.computed} + */ + identifierField: computed.alias('field.identifierField'), + + /** + * aliases the data type for the field + * @type {Ember.computed} + */ + dataType: computed.alias('field.dataType'), + + /** + * Checks if the field format drop-down should be disabled based on the type of the field + * @type {Ember.computed} + */ + isFieldFormatDisabled: computed('field.identifierType', function() { + const identifierType = get(this, 'field.identifierType'); + return isMixedId(identifierType) || isCustomId(identifierType); + }).readOnly(), + + /** + * Returns a computed value for the field identifierType + * isSubject flag on the field is represented as MemberId(Subject Member Id) + * @type {Ember.computed} + */ + identifierType: computed('field.identifierType', 'field.isSubject', function() { + const identifierType = get(this, 'field.identifierType'); + + return identifierType === fieldIdentifierTypes.member.value && get(this, 'field.isSubject') + ? fieldIdentifierTypes.subjectMember.value + : identifierType; + }).readOnly(), + + /** + * A list of field formats that are determined based on the field identifierType + * @type {Ember.computed} + */ + fieldFormats: computed('field.identifierType', function() { + const identifierType = get(this, 'field.identifierType'); + const logicalTypesForIds = get(this, 'logicalTypesForIds'); + + const mixed = isMixedId(identifierType); + const custom = isCustomId(identifierType); + + let fieldFormats = fieldIdentifierTypeIds.includes(identifierType) + ? logicalTypesForIds + : get(this, 'logicalTypesForGeneric'); + const urnFieldFormat = logicalTypesForIds.findBy('value', 'URN'); + fieldFormats = mixed ? urnFieldFormat : fieldFormats; + fieldFormats = custom ? void 0 : fieldFormats; + + return fieldFormats; + }), + + /** + * The fields logical type, rendered as an Object + * @type {Ember.computed} + */ + logicalType: computed('field.logicalType', function() { + const fieldFormats = get(this, 'fieldFormats'); + const logicalType = get(this, 'field.logicalType'); + + // Same object reference for equality comparision + return Array.isArray(fieldFormats) ? fieldFormats.findBy('value', logicalType) : fieldFormats; + }), + + /** + * The field security classification + * @type {Ember.computed} + */ + classification: computed('field.classification', 'field.identifierType', function() { + const identifierType = get(this, 'field.identifierType'); + const mixed = isMixedId(identifierType); + // Filtered list of id logical types that end with urn, or have no value + const urnFieldFormat = get(this, 'logicalTypesForIds').findBy('value', 'URN'); + + return get(this, 'field.classification') || (mixed && defaultFieldDataTypeClassification[urnFieldFormat.value]); + }), + + actions: { + /** + * Handles UI changes to the field identifierType + * @param {string} value + */ + onFieldIdentifierTypeChange({ value }) { + const { onFieldIdentifierTypeChange } = this.attrs; + if (typeof onFieldIdentifierTypeChange === 'function') { + onFieldIdentifierTypeChange(get(this, 'field'), { value }); + } + }, + + /** + * Handles the updates when the field logical type changes on this field + * @param {Event|null} e + */ + onFieldLogicalTypeChange(e) { + const { value } = e || {}; + const { onFieldLogicalTypeChange } = this.attrs; + if (typeof onFieldLogicalTypeChange === 'function') { + onFieldLogicalTypeChange(get(this, 'field'), { value }); + } + }, + + /** + * Handles UI change to field security classification + * @param {string} value + */ + onFieldClassificationChange({ value }) { + const { onFieldClassificationChange } = this.attrs; + if (typeof onFieldClassificationChange === 'function') { + onFieldClassificationChange(get(this, 'field'), { value }); + } + } + } +}); diff --git a/wherehows-web/app/components/dataset-compliance.js b/wherehows-web/app/components/dataset-compliance.js index e34cdf8ccf..f74bc98761 100644 --- a/wherehows-web/app/components/dataset-compliance.js +++ b/wherehows-web/app/components/dataset-compliance.js @@ -10,6 +10,7 @@ import { compliancePolicyStrings } from 'wherehows-web/constants'; import { isPolicyExpectedShape } from 'wherehows-web/utils/datasets/functions'; +import scrollMonitor from 'scrollmonitor'; const { assert, @@ -20,7 +21,6 @@ const { setProperties, getProperties, getWithDefault, - isEmpty, String: { htmlSafe } } = Ember; @@ -105,7 +105,7 @@ 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.} */ -const fieldIdentifierTypeIds = Object.keys(fieldIdentifierTypes) +export const fieldIdentifierTypeIds = Object.keys(fieldIdentifierTypes) .map(fieldIdentifierType => fieldIdentifierTypes[fieldIdentifierType]) .filter(({ isId }) => isId) .mapBy('value'); @@ -127,6 +127,12 @@ const cachedLogicalTypes = logicalType => })); }); +// Map logicalTypes to options consumable by DOM +export const logicalTypesForIds = cachedLogicalTypes('id'); + +// Map generic logical type to options consumable in DOM +export const logicalTypesForGeneric = cachedLogicalTypes('generic'); + export default Component.extend({ sortColumnWithName: 'identifierField', filterBy: 'identifierField', @@ -156,6 +162,75 @@ export default Component.extend({ this.validateAttrs(); }, + /** + * @override + */ + didRender() { + this._super(...arguments); + // Hides DOM elements that are not currently visible in the UI and unhides them once the user scrolls the + // elements into view + this.enableDomCloaking(); + }, + + /** + * A `lite` / intermediary step to occlusion culling, this helps to improve the rendering of + * elements that are currently rendered in the viewport by hiding that aren't. + * Setting them to visibility hidden doesn't remove them from the document flow, but the browser + * doesn't have to deal with layout for the affected elements since they are off-screen + */ + enableDomCloaking() { + const [dom] = this.$('.dataset-compliance-fields'); + const triggerCount = 100; + if (dom) { + const rows = dom.querySelectorAll('tbody tr'); + + // if we already have watchers for elements, or if the elements previously cached are no longer valid, + // e.g. those elements were destroyed when new data was received, pagination etc + if (rows.length > triggerCount && (!this.complianceWatchers || !this.complianceWatchers.has(rows[0]))) { + /** + * If an item is not in the viewport add a class to occlude it + */ + const cloaker = function() { + if (!this.isInViewport) { + return this.watchItem.classList.add('compliance-row--off-screen'); + } + this.watchItem.classList.remove('compliance-row--off-screen'); + }; + this.watchers = []; + + // Retain a weak reference to DOM nodes + this.complianceWatchers = new WeakMap( + [...rows].map(row => { + const watcher = scrollMonitor.create(row); + watcher['stateChange'](cloaker); + cloaker.call(watcher); + this.watchers = [...this.watchers, watcher]; + + return [watcher.watchItem, watcher]; + }) + ); + } + } + }, + + /** + * Cleans up the artifacts from the dom cloaking operation, drops references held by scroll monitor + */ + disableDomCloaking() { + if (!this.watchers || !Array.isArray(this.watchers)) { + return; + } + + this.watchers.forEach(watcher => watcher.destroy()); + }, + + /** + * @override + */ + willDestroyElement() { + this.disableDomCloaking(); + }, + /** * Ensure that props received from on this component * are valid, otherwise flag @@ -173,10 +248,10 @@ export default Component.extend({ }, // Map logicalTypes to options consumable by DOM - idLogicalTypes: cachedLogicalTypes('id'), + idLogicalTypes: logicalTypesForIds, // Map generic logical type to options consumable in DOM - genericLogicalTypes: cachedLogicalTypes('generic'), + genericLogicalTypes: logicalTypesForGeneric, // Map classifiers to options better consumed in DOM classifiers: ['', ...classifiers.sort()].map(value => ({ @@ -196,17 +271,17 @@ export default Component.extend({ * tracking header. * Used to indicate to viewer that these fields are hidden. */ - containsHiddenTrackingFields: computed('truncatedSchemaFields.length', function() { - // If their is a diff in complianceDataFields and truncatedSchemaFields, + containsHiddenTrackingFields: computed('truncatedColumnFields.length', function() { + // If their is a diff in schemaFieldNamesMappedToDataTypes and truncatedColumnFields, // then we have hidden tracking fields - return get(this, 'truncatedSchemaFields.length') !== get(this, 'complianceDataFields.length'); + return get(this, 'truncatedColumnFields.length') !== get(this, 'schemaFieldNamesMappedToDataTypes.length'); }), /** * @type {Array.} Filters the mapped compliance data fields without `kafka type` * tracking headers */ - truncatedSchemaFields: computed('schemaFieldNamesMappedToDataTypes', function() { + truncatedColumnFields: computed('schemaFieldNamesMappedToDataTypes', function() { return getWithDefault(this, 'schemaFieldNamesMappedToDataTypes', []).filter( ({ fieldName }) => !isTrackingHeaderField(fieldName) ); @@ -270,107 +345,76 @@ export default Component.extend({ * privacyCompliancePolicy.complianceEntities. * The returned list is a map of fields with current or default privacy properties */ - complianceDataFields: computed( - `${policyComplianceEntitiesKey}.@each.identifierType`, - `${policyComplianceEntitiesKey}.[]`, - policyFieldClassificationKey, - 'truncatedSchemaFields', - function() { - /** - * Retrieves an attribute on the `policyComplianceEntitiesKey` where the identifierField is the same as the - * provided field name - * @param attribute - * @param fieldName - * @return {null} - */ - const getAttributeOnField = (attribute, fieldName) => { - const complianceEntities = get(this, policyComplianceEntitiesKey) || []; - // 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; + mergeComplianceEntitiesAndColumnFields(columnIdFieldsToCurrentPrivacyPolicy = {}, truncatedColumnFields = []) { + return truncatedColumnFields.map(({ fieldName: identifierField, dataType }) => { + const { [identifierField]: { identifierType, isSubject, logicalType } } = columnIdFieldsToCurrentPrivacyPolicy; + const { [identifierField]: classification } = get(this, policyFieldClassificationKey) || {}; + return { + identifierField, + dataType, + identifierType, + isSubject, + logicalType, + classification }; + }); + }, - /** - * Get value for a list of attributes - * @param {Array} attributes list of attribute keys to pull from - * sourceField - * @param {String} fieldName name of the field to lookup - * @return {Array} list of attribute values - */ - const getAttributesOnField = (attributes = [], fieldName) => - attributes.map(attr => getAttributeOnField(attr, fieldName)); + /** + * + * @param {Array} columnFieldNames + * @return {*|{}|any} + */ + mapColumnIdFieldsToCurrentPrivacyPolicy(columnFieldNames) { + const complianceEntities = get(this, policyComplianceEntitiesKey) || []; + const getKeysOnField = (keys = [], fieldName, source = []) => { + const sourceField = source.find(({ identifierField }) => identifierField === fieldName) || {}; + let ret = {}; - // Set default or if already in policy, retrieve current values from - // privacyCompliancePolicy.complianceEntities - return getWithDefault(this, 'truncatedSchemaFields', []).map(({ fieldName: identifierField, dataType }) => { - const [identifierType, isSubject, logicalType] = getAttributesOnField( - ['identifierType', 'isSubject', 'logicalType'], - identifierField - ); - /** - * Flag indicating that the field has an identifierType matching a generic type - * @type {Boolean} - */ - const isMixed = identifierType === fieldIdentifierTypes.generic.value; - /** - * Flag indicating that the field has an identifierType matching a custom id type - * @type {Boolean} - */ - const isCustom = identifierType === fieldIdentifierTypes.custom.value; - // Runtime converts the identifierType to subjectMember if the isSubject flag is true - const computedIdentifierType = identifierType === fieldIdentifierTypes.member.value && isSubject - ? fieldIdentifierTypes.subjectMember.value - : identifierType; - const idLogicalTypes = get(this, 'idLogicalTypes'); - // Filtered list of id logical types that end with urn, or have no value - const urnFieldFormat = idLogicalTypes.findBy('value', 'URN'); - // Get the current classification list - const fieldClassification = get(this, policyFieldClassificationKey) || {}; - // The field formats applicable to the current identifierType - let fieldFormats = fieldIdentifierTypeIds.includes(identifierType) - ? idLogicalTypes - : get(this, 'genericLogicalTypes'); - /** - * If field is a mixed identifier, avail only the urnFieldFormat, otherwise use the prev determined fieldFormats - * @type {any|Object} - */ - fieldFormats = isMixed ? urnFieldFormat : fieldFormats; - fieldFormats = isCustom ? void 0 : fieldFormats; - /** - * An object referencing the fieldFormat for this field - * @type {any|Object} - */ - const logicalTypeObject = Array.isArray(fieldFormats) - ? fieldFormats.findBy('value', logicalType) - : fieldFormats; + for (const [key, value] of Object.entries(sourceField)) { + if (keys.includes(key)) { + ret = { ...ret, [key]: value }; + } + } + return ret; + }; - return { - dataType, - identifierField, - fieldFormats, - // Boolean flag indicating that the list of field formats is unchanging - isFieldFormatDisabled: isMixed || isCustom, - identifierType: computedIdentifierType, - // Check specific use case for urn only field format / logicalType - classification: - fieldClassification[identifierField] || - (isMixed && defaultFieldDataTypeClassification[urnFieldFormat.value]), - // Same object reference for equality comparision - logicalType: logicalTypeObject - }; - }); + return columnFieldNames.reduce((acc, identifierField) => { + const currentPrivacyAttrs = getKeysOnField( + ['identifierType', 'isSubject', 'logicalType'], + identifierField, + complianceEntities + ); + + return { ...acc, ...{ [identifierField]: currentPrivacyAttrs } }; + }, {}); + }, + + /** + * Computed prop over the current Id fields in the Privacy Policy + * @type {Ember.computed} + */ + columnIdFieldsToCurrentPrivacyPolicy: computed( + 'truncatedColumnFields', + `${policyComplianceEntitiesKey}.[]`, + function() { + const columnFieldNames = get(this, 'truncatedColumnFields').map(({ fieldName }) => fieldName); + return this.mapColumnIdFieldsToCurrentPrivacyPolicy(columnFieldNames); } ), + /** + * Caches a reference to the generated list of merged data between the column api and the current compliance entities list + * @type {Ember.computed} + */ + mergedComplianceEntitiesAndColumnFields: computed('columnIdFieldsToCurrentPrivacyPolicy', function() { + // truncatedColumnFields is a dependency for cp columnIdFieldsToCurrentPrivacyPolicy, so no need to dep on that directly + return this.mergeComplianceEntitiesAndColumnFields( + get(this, 'columnIdFieldsToCurrentPrivacyPolicy'), + get(this, 'truncatedColumnFields') + ); + }), + /** * Checks that each entity in sourceEntities has a generic * @param {Array} sourceEntities = [] the source entities to be matched against @@ -453,38 +497,49 @@ export default Component.extend({ // Current list of compliance entities on policy const complianceEntities = get(this, policyComplianceEntitiesKey); // All candidate fields that can be on policy, excluding tracking type fields - const datasetFields = get(this, 'complianceDataFields'); - const fieldsCurrentlyInComplianceList = complianceEntities.mapBy('identifierField'); + const datasetFields = get( + this, + 'mergedComplianceEntitiesAndColumnFields' + ).map(({ identifierField, identifierType, isSubject, logicalType }) => ({ + identifierField, + identifierType, + isSubject, + logicalType + })); // Fields that do not have a logicalType, and no identifierType or identifierType is `fieldIdentifierTypes.none` - const unformattedFields = datasetFields.filter( - ({ identifierType, logicalType }) => - !logicalType && (fieldIdentifierTypes.none.value === identifierType || !identifierType) + const { formatted, unformatted } = datasetFields.reduce( + ({ formatted, unformatted }, field) => { + const { identifierType, logicalType } = getProperties(field, ['identifierType', 'logicalType']); + if (!logicalType && (fieldIdentifierTypes.none.value === identifierType || !identifierType)) { + unformatted = [...unformatted, field]; + } else { + formatted = [...formatted, field]; + } + return { formatted, unformatted }; + }, + { formatted: [], unformatted: [] } ); let isConfirmed = true; + let unformattedComplianceEntities = []; // If there are unformatted fields, require confirmation from user - 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 - })); + if (unformatted.length) { + unformattedComplianceEntities = unformatted.map(({ identifierField }) => ({ + identifierField, + identifierType: fieldIdentifierTypes.none.value, + isSubject: null, + logicalType: void 0 + })); isConfirmed = confirm( - `There are ${unformattedFields.length} non-ID fields that have no field format specified. ` + + `There are ${unformatted.length} non-ID fields that have no field format specified. ` + `Are you sure they don't contain any of the following PII?\n\n` + `Name, Email, Phone, Address, Location, IP Address, Payment Info, Password, National ID, Device ID etc.` ); - - // If the user confirms that this is ok, apply the unformatted fields on the current compliance list - // to be saved - isConfirmed && complianceEntities.setObjects([...complianceEntities, ...unformattedComplianceEntities]); } + // If the user confirms that this is ok, apply the unformatted fields on the current compliance list + // to be saved + isConfirmed && complianceEntities.setObjects([...formatted, ...unformattedComplianceEntities]); return isConfirmed; }, @@ -585,35 +640,20 @@ export default Component.extend({ * @param {String} identifierType */ onFieldIdentifierTypeChange({ identifierField, logicalType }, { value: identifierType }) { - const complianceList = get(this, policyComplianceEntitiesKey); + const currentComplianceEntities = get(this, 'mergedComplianceEntitiesAndColumnFields'); // A reference to the current field in the compliance list if it exists - const currentFieldInComplianceList = complianceList.findBy('identifierField', identifierField); + const currentFieldInComplianceList = currentComplianceEntities.findBy('identifierField', identifierField); const subjectIdString = fieldIdentifierTypes.subjectMember.value; // Some rendered identifierTypes may be masks of other underlying types, e.g. subjectId Member type is really // a memberId with an attribute of isSubject in the affirmative const unwrappedIdentifierType = subjectIdString === identifierType ? fieldIdentifierTypes.member.value : identifierType; - const updatedEntity = Object.assign({}, currentFieldInComplianceList, { - identifierField, + setProperties(currentFieldInComplianceList, { identifierType: unwrappedIdentifierType, isSubject: subjectIdString === identifierType ? true : null, - // If the field is currently not in the complianceList, - // we will set the logicalType to be the provided value, otherwise, set to undefined - // since the next step removes it from the updated list - logicalType: !currentFieldInComplianceList ? logicalType : void 0 + logicalType: void 0 }); - let transientComplianceList = complianceList; - - // If the identifierField is in the current compliance list, - // create a filtered list excluding the identifierField before updating the list - if (currentFieldInComplianceList) { - transientComplianceList = complianceList.filter( - ({ identifierField: fieldName }) => fieldName !== identifierField - ); - } - - complianceList.setObjects([updatedEntity, ...transientComplianceList]); // Set the defaultClassification for the identifierField, // although the classification is based on the logicalType, // an identifierField may only have one valid logicalType for it's given identifierType @@ -627,23 +667,18 @@ export default Component.extend({ * @param {Event} e the DOM change event * @return {*} */ - onFieldLogicalTypeChange(field, e) { + onFieldLogicalTypeChange(field, { value: logicalType } = {}) { const { identifierField } = field; - const { value: logicalType } = e || {}; - let sourceIdentifierField = get(this, policyComplianceEntitiesKey).findBy('identifierField', identifierField); // If the identifierField does not current exist, invoke onFieldIdentifierChange to add it on the compliance list - if (!sourceIdentifierField) { + if (!field) { this.actions.onFieldIdentifierTypeChange.call( this, - { - identifierField, - logicalType - }, + { identifierField, logicalType }, { value: fieldIdentifierTypes.none.value } ); } else { - set(sourceIdentifierField, 'logicalType', logicalType); + set(field, 'logicalType', logicalType); } return this.setDefaultClassification({ identifierField }, { value: logicalType }); @@ -656,6 +691,10 @@ export default Component.extend({ * @return {*} */ onFieldClassificationChange({ identifierField }, { value: classification = null }) { + const currentFieldInComplianceList = get(this, 'mergedComplianceEntitiesAndColumnFields').findBy( + 'identifierField', + identifierField + ); let fieldClassification = get(this, policyFieldClassificationKey); let updatedFieldClassification = {}; // For datasets initially without a fieldClassification, the default value is null @@ -675,6 +714,8 @@ export default Component.extend({ updatedFieldClassification = Object.assign({}, fieldClassification, { [identifierField]: classification }); } + // Apply the updated classification value to the current instance of the field in working copy + set(currentFieldInComplianceList, 'classification', classification); set(this, policyFieldClassificationKey, updatedFieldClassification); }, diff --git a/wherehows-web/app/routes/datasets/dataset.js b/wherehows-web/app/routes/datasets/dataset.js index eb4a003ab0..d7b0b0b06b 100644 --- a/wherehows-web/app/routes/datasets/dataset.js +++ b/wherehows-web/app/routes/datasets/dataset.js @@ -33,24 +33,13 @@ export default Route.extend({ //TODO: DSS-6632 Correct server-side if status:error and record not found but response is 200OK setupController(controller, model) { let source = ''; - var id = 0; - var urn = ''; + let id = 0; + let urn = ''; /** * Series of chain-able functions invoked to set set properties on the controller required for dataset tab sections - * @type {{privacyCompliancePolicy: ((id)), securitySpecification: ((id)), datasetSchemaFieldNamesAndTypes: ((id))}} */ const fetchThenSetOnController = { - async complianceInfo(controller, { id }) { - const response = await Promise.resolve(getJSON(getDatasetPrivacyUrl(id))); - const { msg, status, complianceInfo = createInitialComplianceInfo(id) } = response; - const isNewComplianceInfo = status === 'failed' && String(msg).includes('actual 0'); - - setProperties(controller, { complianceInfo, isNewComplianceInfo }); - - return this; - }, - datasetSchemaFieldNamesAndTypes(controller, { id }) { Promise.resolve(getJSON(datasetUrl(id))).then(({ dataset: { schema } = { schema: undefined } } = {}) => { /** @@ -132,6 +121,73 @@ export default Route.extend({ }) ) .forEach(func => func()); + + /** + * Fetches the current compliance policy for the rendered dataset + * @param {number} id the id of the dataset + * @return {Promise.<{complianceInfo: *, isNewComplianceInfo: boolean}>} + */ + const getComplianceInfo = async id => { + const response = await Promise.resolve(getJSON(getDatasetPrivacyUrl(id))); + const { msg, status, complianceInfo = createInitialComplianceInfo(id) } = response; + const isNewComplianceInfo = status === 'failed' && String(msg).includes('actual 0'); + + return { complianceInfo, isNewComplianceInfo }; + }; + + /** + * Fetch the datasetColumn + * @param {number} id the id of the dataset + */ + getDatasetColumn = id => + Promise.resolve(getJSON(getDatasetColumnUrl(id))) + .then(({ status, columns }) => { + if (status === 'ok') { + if (columns && columns.length) { + const columnsWithHTMLComments = columns.map(column => { + const { comment } = column; + + if (comment) { + // TODO: DSS-6122 Refactor global function reference + column.commentHtml = window.marked(comment).htmlSafe(); + } + + return column; + }); + + controller.set('hasSchemas', true); + controller.set('schemas', columnsWithHTMLComments); + + // TODO: DSS-6122 Refactor direct method invocation on controller + controller.buildJsonView(); + // TODO: DSS-6122 Refactor setTimeout, + // global function reference + setTimeout(window.initializeColumnTreeGrid, 500); + } + + return columns; + } + + return Promise.reject(new Error('Dataset columns request failed.')); + }) + .then(columns => columns.map(({ dataType, fullFieldPath }) => ({ dataType, fieldName: fullFieldPath }))) + .catch(() => setProperties(controller, { hasSchemas: false, schemas: null })); + + /** + * async IIFE sets the the complianceInfo and schemaFieldNamesMappedToDataTypes + * at once so observers will be buffered + * @param {number} id the dataset id + * @return {Promise.} + */ + (async id => { + const [columns, privacy] = await Promise.all([getDatasetColumn(id), getComplianceInfo(id)]); + const { complianceInfo, isNewComplianceInfo } = privacy; + setProperties(controller, { + complianceInfo, + isNewComplianceInfo, + schemaFieldNamesMappedToDataTypes: columns + }); + })(id); } Promise.resolve(getJSON(getDatasetInstanceUrl(id))) @@ -207,48 +263,6 @@ export default Route.extend({ } }); - getDatasetColumn = id => - Promise.resolve(getJSON(getDatasetColumnUrl(id))) - .then(({ status, columns }) => { - if (status === 'ok') { - if (columns && columns.length) { - const columnsWithHTMLComments = columns.map(column => { - const { comment } = column; - - if (comment) { - // TODO: DSS-6122 Refactor global function reference - column.commentHtml = window.marked(comment).htmlSafe(); - } - - return column; - }); - - controller.set('hasSchemas', true); - controller.set('schemas', columnsWithHTMLComments); - - // TODO: DSS-6122 Refactor direct method invocation on controller - controller.buildJsonView(); - // TODO: DSS-6122 Refactor setTimeout, - // global function reference - setTimeout(window.initializeColumnTreeGrid, 500); - } - - return columns; - } - - return Promise.reject(new Error('Dataset columns request failed.')); - }) - .then(columns => columns.map(({ dataType, fullFieldPath }) => ({ dataType, fieldName: fullFieldPath }))) - .then(set.bind(Ember, controller, 'schemaFieldNamesMappedToDataTypes')) - .catch(() => - setProperties(controller, { - hasSchemas: false, - schemas: null - }) - ); - - getDatasetColumn(id); - if (source.toLowerCase() !== 'pinot') { Promise.resolve(getJSON(getDatasetPropertiesUrl(id))) .then(({ status, properties }) => { diff --git a/wherehows-web/app/routes/login.js b/wherehows-web/app/routes/login.js index c41974c025..908a2a79d4 100644 --- a/wherehows-web/app/routes/login.js +++ b/wherehows-web/app/routes/login.js @@ -1,10 +1,6 @@ import Ember from 'ember'; -const { - Route, - get, - inject: { service } -} = Ember; +const { Route, get, inject: { service } } = Ember; export default Route.extend({ session: service(), @@ -17,5 +13,16 @@ export default Route.extend({ if (get(this, 'session.isAuthenticated')) { this.transitionTo('index'); } + }, + + /** + * Overrides the default method with a custom op + * renders the default template into the login outlet + * @override + */ + renderTemplate() { + this.render({ + outlet: 'login' + }); } }); diff --git a/wherehows-web/app/styles/base/_typography.scss b/wherehows-web/app/styles/base/_typography.scss index 340974fc58..bd45b3cfd4 100644 --- a/wherehows-web/app/styles/base/_typography.scss +++ b/wherehows-web/app/styles/base/_typography.scss @@ -3,7 +3,7 @@ */ body { color: $text-color; - font: normal 100 150% / 1.4 $text-font-stack; + font: normal 300 150% / 1.4 $text-font-stack; } h1, h2, h3, h4, h5, h6 { @@ -12,4 +12,4 @@ h1, h2, h3, h4, h5, h6 { th { font-weight: normal; -} \ No newline at end of file +} diff --git a/wherehows-web/app/styles/components/dataset-compliance/_compliance-container.scss b/wherehows-web/app/styles/components/dataset-compliance/_compliance-container.scss index 634ccca3cf..7cd4226c43 100644 --- a/wherehows-web/app/styles/components/dataset-compliance/_compliance-container.scss +++ b/wherehows-web/app/styles/components/dataset-compliance/_compliance-container.scss @@ -16,3 +16,10 @@ .dataset-field-value { visibility: hidden; } + +/// Visual state for compliance elements +.compliance-row { + &--off-screen { + visibility: hidden; + } +} diff --git a/wherehows-web/app/templates/application.hbs b/wherehows-web/app/templates/application.hbs index 2b8d327602..40e4e545d5 100644 --- a/wherehows-web/app/templates/application.hbs +++ b/wherehows-web/app/templates/application.hbs @@ -12,5 +12,5 @@ {{partial "main"}} {{else}} - {{outlet}} + {{outlet "login"}} {{/if}} diff --git a/wherehows-web/app/templates/components/dataset-compliance-row.hbs b/wherehows-web/app/templates/components/dataset-compliance-row.hbs new file mode 100644 index 0000000000..239c1d0574 --- /dev/null +++ b/wherehows-web/app/templates/components/dataset-compliance-row.hbs @@ -0,0 +1,13 @@ +{{yield (hash + cell=(component 'dataset-table-cell') + isFieldFormatDisabled=isFieldFormatDisabled + identifierType=identifierType + identifierField=identifierField + dataType=dataType + fieldFormats=fieldFormats + logicalType=logicalType + classification=classification + onFieldIdentifierTypeChange=(action 'onFieldIdentifierTypeChange') + onFieldClassificationChange=(action 'onFieldClassificationChange') + onFieldLogicalTypeChange=(action 'onFieldLogicalTypeChange') + )}} diff --git a/wherehows-web/app/templates/components/dataset-compliance.hbs b/wherehows-web/app/templates/components/dataset-compliance.hbs index 2549b3541c..294134bdf4 100644 --- a/wherehows-web/app/templates/components/dataset-compliance.hbs +++ b/wherehows-web/app/templates/components/dataset-compliance.hbs @@ -166,54 +166,56 @@ {{/if}} - {{#dataset-table - class="nacho-table--stripped" - fields=complianceDataFields - sortColumnWithName=sortColumnWithName - filterBy=filterBy - sortDirection=sortDirection - searchTerm=searchTerm as |table| - }} - - - - {{#table.head as |head|}} - {{#head.column columnName="identifierField"}}Field{{/head.column}} - {{#head.column columnName="dataType"}}Data Type{{/head.column}} - {{#head.column class="nacho-table-cell-wrapped"}} - Member, Organization, or Group, ID's - - - More Info + {{#if mergedComplianceEntitiesAndColumnFields.length}} + {{#dataset-table + class="nacho-table--stripped dataset-compliance-fields" + fields=mergedComplianceEntitiesAndColumnFields + sortColumnWithName=sortColumnWithName + filterBy=filterBy + sortDirection=sortDirection + tableRowComponent='dataset-compliance-row' + searchTerm=searchTerm as |table| + }} + + + + {{#table.head as |head|}} + {{#head.column columnName="identifierField"}}Field{{/head.column}} + {{#head.column columnName="dataType"}}Data Type{{/head.column}} + {{#head.column class="nacho-table-cell-wrapped"}} + Member, Organization, or Group, ID's + + + More Info - - - - {{/head.column}} - {{#head.column}} - Field Format? - - - More Info + + + + {{/head.column}} + {{#head.column}} + Field Format? + + + More Info - - - - {{/head.column}} - {{#head.column}} - Security Classification - + + + + {{/head.column}} + {{#head.column}} + Security Classification + @@ -223,51 +225,62 @@ text=helpText.classification }} - - {{/head.column}} - {{/table.head}} - {{#table.body as |body|}} - {{#each (sort-by table.sortBy table.data) as |field|}} - {{#body.row as |row|}} - {{#row.cell}} - {{field.identifierField}} - {{/row.cell}} - {{#row.cell}} - {{field.dataType}} - {{/row.cell}} - {{#row.cell}} - {{ember-selector - disabled=(not isEditing) - values=fieldIdentifierOptions - selected=field.identifierType - selectionDidChange=(action "onFieldIdentifierTypeChange" field) - }} - {{/row.cell}} - {{#row.cell}} - {{#power-select - options=field.fieldFormats - selected=field.logicalType - disabled=(or field.isFieldFormatDisabled (not isEditing)) - placeholder="Select Format" - allowClear=true - searchField="label" - triggerClass="ember-power-select-trigger-search" - onchange=(action "onFieldLogicalTypeChange" field) as |fieldFormat|}} - {{fieldFormat.label}} - {{/power-select}} - {{/row.cell}} - {{#row.cell}} - {{ember-selector - class="nacho-select--hidden-state" - values=classifiers - selected=field.classification - disabled=(or (not isEditing) (not field.logicalType)) - selectionDidChange=(action "onFieldClassificationChange" field) - }} - {{/row.cell}} - {{/body.row}} - {{/each}} - {{/table.body}} - {{/dataset-table}} + + {{/head.column}} + {{/table.head}} + {{#table.body as |body|}} + {{#each (sort-by table.sortBy table.data) as |field|}} + {{#body.row + field=field + onFieldLogicalTypeChange=(action 'onFieldLogicalTypeChange') + onFieldClassificationChange=(action 'onFieldClassificationChange') + onFieldIdentifierTypeChange=(action 'onFieldIdentifierTypeChange')as |row|}} + + {{#row.cell}} + {{row.identifierField}} + {{/row.cell}} + + {{#row.cell}} + {{row.dataType}} + {{/row.cell}} + + {{#row.cell}} + {{ember-selector + disabled=(not isEditing) + values=fieldIdentifierOptions + selected=(readonly row.identifierType) + selectionDidChange=(action row.onFieldIdentifierTypeChange) + }} + {{/row.cell}} + + {{#row.cell}} + {{#power-select + options=row.fieldFormats + selected=row.logicalType + disabled=(or row.isFieldFormatDisabled (not isEditing)) + placeholder="Select Format" + allowClear=true + searchField="label" + triggerClass="ember-power-select-trigger-search" + onchange=(action row.onFieldLogicalTypeChange) as |fieldFormat|}} + {{fieldFormat.label}} + {{/power-select}} + {{/row.cell}} + + {{#row.cell}} + {{ember-selector + class="nacho-select--hidden-state" + values=classifiers + selected=row.classification + disabled=(or (not isEditing) (not row.logicalType)) + selectionDidChange=(action row.onFieldClassificationChange) + }} + {{/row.cell}} + {{/body.row}} + {{/each}} + {{/table.body}} + {{/dataset-table}} + {{/if}} + {{yield}} diff --git a/wherehows-web/app/templates/components/dataset-table.hbs b/wherehows-web/app/templates/components/dataset-table.hbs index b94fd31569..bd9d834dff 100644 --- a/wherehows-web/app/templates/components/dataset-table.hbs +++ b/wherehows-web/app/templates/components/dataset-table.hbs @@ -17,7 +17,6 @@ sortDirection=(readonly sortDirection) sortDidChange=(action "sortDidChange")) body=(component tableBodyComponent - tableRowComponent=tableRowComponent - ) + tableRowComponent=tableRowComponent) foot=(component tableFooterComponent) )}} diff --git a/wherehows-web/app/templates/dataset.hbs b/wherehows-web/app/templates/dataset.hbs deleted file mode 100644 index 6443fa8542..0000000000 --- a/wherehows-web/app/templates/dataset.hbs +++ /dev/null @@ -1,424 +0,0 @@ -
-
-
-
- -
-
-
-
-
-

{{ model.name }}

-
-
- -
-
- {{dataset-owner-list owners=owners datasetName=model.name}} - {{#if hasinstances}} -
- Instances: - -
- {{/if}} - {{#if hasversions}} -
- Versions: - -
- {{/if}} -
- -{{#if tabview}} - -
- {{#unless isPinot}} -
- {{#dataset-property hasProperty=hasProperty properties=properties}} - {{/dataset-property}} -
- {{/unless}} -
- {{#dataset-comments dataset=this}} - {{/dataset-comments}} -
-
- {{#dataset-schema hasSchemas=hasSchemas isTable=isTable isJSON=isJSON schemas=schemas dataset=model}} - {{/dataset-schema}} -
-
- {{dataset-author owners=owners - ownerTypes=ownerTypes - showMsg=showMsg - alertType=alertType - ownerMessage=ownerMessage - parentController=this}} -
- {{#unless isSFDC}} -
- {{#dataset-sample hasSamples=hasSamples isPinot=isPinot columns=columns samples=samples}} - {{/dataset-sample}} -
- {{/unless}} -
- {{#dataset-impact hasImpacts=hasImpacts impacts=impacts}} - {{/dataset-impact}} -
-
- {{#dataset-relations hasDepends=hasDepends depends=depends hasReferences=hasReferences references=references}} - {{/dataset-relations}} -
-
- {{#dataset-access hasAccess=hasAccess accessibilities=accessibilities}} - {{/dataset-access}} -
-
- {{dataset-compliance privacyCompliancePolicy=privacyCompliancePolicy - isNewPrivacyCompliancePolicy=isNewPrivacyCompliancePolicy - datasetSchemaFieldsAndTypes=datasetSchemaFieldsAndTypes - onSave=(action "savePrivacyCompliancePolicy") - onReset=(action "resetPrivacyCompliancePolicy")}} -
-
- {{dataset-confidential securitySpecification=securitySpecification - isNewSecuritySpecification=isNewSecuritySpecification - datasetSchemaFieldsAndTypes=datasetSchemaFieldsAndTypes - onSave=(action "saveSecuritySpecification") - onReset=(action "resetSecuritySpecification")}} -
-
-{{else}} -
- {{#unless isPinot}} -
- -
-
-
- {{#dataset-property hasProperty=hasProperty properties=properties}} - {{/dataset-property}} -
-
-
-
- {{/unless}} - -
- -
-
- {{#dataset-comments dataset=this}} - {{/dataset-comments}} -
-
-
- -
- -
-
- {{#dataset-schema hasSchemas=hasSchemas isTable=isTable isJSON=isJSON schemas=schemas dataset=model}} - {{/dataset-schema}} -
-
-
- -
- -
-
- {{dataset-author owners=owners - ownerTypes=ownerTypes - showMsg=showMsg - alertType=alertType - ownerMessage=ownerMessage - parentController=this}} -
-
-
- - {{#unless isSFDC}} -
- -
-
- {{#dataset-sample hasSamples=hasSamples isPinot=isPinot columns=columns samples=samples}} - {{/dataset-sample}} -
-
-
- {{/unless}} - -
- -
-
- {{#dataset-impact hasImpacts=hasImpacts impacts=impacts}} - {{/dataset-impact}} -
-
-
- -
- -
-
- {{#dataset-relations hasDepends=hasDepends depends=depends hasReferences=hasReferences references=references}} - {{/dataset-relations}} -
-
-
- -
- -
-
- {{#dataset-access hasAccess=hasAccess accessibilities=accessibilities}} - {{/dataset-access}} -
-
-
- -
- -
-
- {{dataset-compliance privacyCompliancePolicy=privacyCompliancePolicy - isNewPrivacyCompliancePolicy=isNewPrivacyCompliancePolicy - datasetSchemaFieldsAndTypes=datasetSchemaFieldsAndTypes - onSave=(action "savePrivacyCompliancePolicy") - onReset=(action "resetPrivacyCompliancePolicy")}} -
-
-
- -
- -
-
- {{dataset-confidential securitySpecification=securitySpecification - isNewSecuritySpecification=isNewSecuritySpecification - datasetSchemaFieldsAndTypes=datasetSchemaFieldsAndTypes - onSave=(action "saveSecuritySpecification") - onReset=(action "resetSecuritySpecification")}} -
-
-
- -
-{{/if}} \ No newline at end of file diff --git a/wherehows-web/app/templates/datasets.hbs b/wherehows-web/app/templates/datasets.hbs index b08939f590..6c71e0f7ea 100644 --- a/wherehows-web/app/templates/datasets.hbs +++ b/wherehows-web/app/templates/datasets.hbs @@ -1,117 +1,41 @@ -
-
-
- {{#unless detailview}} -
-
-
- -
-
-
-
-
    - {{#unless first}} - - {{/unless}} -
  • - {{ model.data.count }} datasets - page {{ model.data.page }} of {{ model.data.totalPages }} -
  • - {{#unless last}} - - {{/unless}} -
-
- {{/unless}} - {{#unless detailview}} - - - {{#each model.data.datasets as |dataset|}} - - - - {{/each}} - -
-
-
-
- {{#link-to 'datasets.dataset' dataset}} - {{ dataset.name }} - {{/link-to}} -
- {{#if dataset.owners}} -
- owner: - {{#each dataset.owners as |owner|}} -

{{ owner.userName }}

- {{/each}} -
- {{/if}} - {{#if dataset.formatedModified}} -
- last modified: - {{ dataset.formatedModified }} -
- {{/if}} -
-
- -
-
-
- {{/unless}} +
+ {{#unless detailview}} +
+
    + {{#unless first}} + + {{/unless}} +
  • + {{ model.data.count }} datasets - page {{ model.data.page }} + of {{ model.data.totalPages }} +
  • + {{#unless last}} + + {{/unless}} +
-
- {{outlet}} -
-
-
\ No newline at end of file + {{/unless}} + {{outlet}} +
diff --git a/wherehows-web/bower.json b/wherehows-web/bower.json index 206c55bcdc..510a6633e0 100644 --- a/wherehows-web/bower.json +++ b/wherehows-web/bower.json @@ -16,7 +16,8 @@ "jsondiffpatch": "^0.2.4", "marked": "^0.3.6", "toastr": "^2.1.3", - "x-editable": "^1.5.1" + "x-editable": "^1.5.1", + "scrollMonitor": "^1.2.3" }, "resolutions": { "ember-cli-shims": "0.1.3", diff --git a/wherehows-web/ember-cli-build.js b/wherehows-web/ember-cli-build.js index 392e5adc67..9dd0c85796 100644 --- a/wherehows-web/ember-cli-build.js +++ b/wherehows-web/ember-cli-build.js @@ -17,7 +17,7 @@ module.exports = function(defaults) { }, fingerprint: { - enabled: true + enabled: EmberApp.env() === 'production' }, 'ember-cli-bootstrap-sassy': { @@ -25,6 +25,7 @@ module.exports = function(defaults) { }, minifyJS: { + enabled: EmberApp.env() === 'production', options: { exclude: ['**/vendor.js', 'legacy-app/**'] } @@ -130,6 +131,8 @@ module.exports = function(defaults) { app.import('bower_components/jsondiffpatch/public/build/jsondiffpatch.min.js'); app.import('bower_components/jsondiffpatch/public/build/jsondiffpatch-formatters.min.js'); app.import('bower_components/x-editable/dist/bootstrap3-editable/js/bootstrap-editable.min.js'); + app.import('bower_components/scrollMonitor/scrollMonitor.js'); + app.import('vendor/shims/scrollmonitor.js'); return app.toTree(new MergeTrees([faFontTree, bsFontTree, treegridImgTree])); }; diff --git a/wherehows-web/package.json b/wherehows-web/package.json index 5dec85a1b1..566738acfa 100644 --- a/wherehows-web/package.json +++ b/wherehows-web/package.json @@ -79,7 +79,7 @@ "lint-staged": { "gitDir": "../", "linters": { - "wherehows-web/app/**/*.js": [ + "wherehows-web/{app,tests}/**/*.js": [ "prettier --print-width 120 --single-quote --write", "git add" ] diff --git a/wherehows-web/tests/integration/components/dataset-compliance-row-test.js b/wherehows-web/tests/integration/components/dataset-compliance-row-test.js new file mode 100644 index 0000000000..b5d9abbf8a --- /dev/null +++ b/wherehows-web/tests/integration/components/dataset-compliance-row-test.js @@ -0,0 +1,24 @@ +import { moduleForComponent, test } from 'ember-qunit'; +import hbs from 'htmlbars-inline-precompile'; + +moduleForComponent('dataset-compliance-row', 'Integration | Component | dataset compliance row', { + integration: true +}); + +test('it renders', function(assert) { + // Set any properties with this.set('myProperty', 'value'); + // Handle any actions with this.on('myAction', function(val) { ... }); + + this.render(hbs`{{dataset-compliance-row}}`); + + assert.equal(this.$().text().trim(), ''); + + // Template block usage: + this.render(hbs` + {{#dataset-compliance-row}} + template block text + {{/dataset-compliance-row}} + `); + + assert.equal(this.$().text().trim(), 'template block text'); +}); diff --git a/wherehows-web/vendor/shims/scrollmonitor.js b/wherehows-web/vendor/shims/scrollmonitor.js new file mode 100644 index 0000000000..699518f5ed --- /dev/null +++ b/wherehows-web/vendor/shims/scrollmonitor.js @@ -0,0 +1,9 @@ +(function() { + function vendorModule() { + 'use strict'; + + return { 'default': self['scrollMonitor'] }; + } + + define('scrollmonitor', [], vendorModule); +})();