mirror of
				https://github.com/datahub-project/datahub.git
				synced 2025-10-30 18:26:58 +00:00 
			
		
		
		
	Merge pull request #899 from theseyi/schemaless-tagging
schemaless dataset tagging
This commit is contained in:
		
						commit
						fc6ff917a5
					
				| @ -1,7 +1,7 @@ | |||||||
| import Ember from 'ember'; | import Ember from 'ember'; | ||||||
| import isTrackingHeaderField from 'wherehows-web/utils/validators/tracking-headers'; | import isTrackingHeaderField from 'wherehows-web/utils/validators/tracking-headers'; | ||||||
| import { | import { | ||||||
|   classifiers, |   securityClassificationDropdownOptions, | ||||||
|   DatasetClassifiers, |   DatasetClassifiers, | ||||||
|   fieldIdentifierTypes, |   fieldIdentifierTypes, | ||||||
|   fieldIdentifierOptions, |   fieldIdentifierOptions, | ||||||
| @ -14,7 +14,7 @@ import { | |||||||
|   logicalTypesForGeneric, |   logicalTypesForGeneric, | ||||||
|   hasPredefinedFieldFormat, |   hasPredefinedFieldFormat, | ||||||
|   getDefaultLogicalType, |   getDefaultLogicalType, | ||||||
|   complianceSteps, |   getComplianceSteps, | ||||||
|   hiddenTrackingFields, |   hiddenTrackingFields, | ||||||
|   isExempt |   isExempt | ||||||
| } from 'wherehows-web/constants'; | } from 'wherehows-web/constants'; | ||||||
| @ -53,13 +53,6 @@ const { | |||||||
|   invalidPolicyData |   invalidPolicyData | ||||||
| } = compliancePolicyStrings; | } = compliancePolicyStrings; | ||||||
| 
 | 
 | ||||||
| /** |  | ||||||
|  * Takes a string, returns a formatted string. Niche , single use case |  | ||||||
|  * for now, so no need to make into a helper |  | ||||||
|  * @param {String} string |  | ||||||
|  */ |  | ||||||
| const formatAsCapitalizedStringWithSpaces = string => string.replace(/[A-Z]/g, match => ` ${match}`).capitalize(); |  | ||||||
| 
 |  | ||||||
| /** | /** | ||||||
|  * List of non Id field data type classifications |  * List of non Id field data type classifications | ||||||
|  * @type {Array} |  * @type {Array} | ||||||
| @ -109,13 +102,20 @@ export default Component.extend({ | |||||||
|    * @type {number} |    * @type {number} | ||||||
|    */ |    */ | ||||||
|   editStepIndex: initialStepIndex, |   editStepIndex: initialStepIndex, | ||||||
|  | 
 | ||||||
|   /** |   /** | ||||||
|    * Converts the hash of complianceSteps to a list of steps |    * Converts the hash of complianceSteps to a list of steps | ||||||
|    * @type {Array<{}>} |    * @type {ComputedProperty<Array<{}>>} | ||||||
|    */ |    */ | ||||||
|   editSteps: Object.keys(complianceSteps) |   editSteps: computed('schemaless', function() { | ||||||
|     .sort() |     const hasSchema = !getWithDefault(this, 'schemaless', false); | ||||||
|     .map(key => complianceSteps[key]), |     const steps = getComplianceSteps(hasSchema); | ||||||
|  | 
 | ||||||
|  |     // Ensure correct step ordering
 | ||||||
|  |     return Object.keys(steps) | ||||||
|  |       .sort() | ||||||
|  |       .map(key => steps[key]); | ||||||
|  |   }), | ||||||
| 
 | 
 | ||||||
|   /** |   /** | ||||||
|    * Handles the transition between steps in the compliance edit wizard |    * Handles the transition between steps in the compliance edit wizard | ||||||
| @ -136,9 +136,9 @@ export default Component.extend({ | |||||||
|       let lastIndex = initialStepIndex; |       let lastIndex = initialStepIndex; | ||||||
| 
 | 
 | ||||||
|       return function() { |       return function() { | ||||||
|         const currentIndex = get(this, 'editStepIndex'); |         const { editStepIndex: currentIndex, editSteps } = getProperties(this, ['editStepIndex', 'editSteps']); | ||||||
|         // the current step in the edit sequence
 |         // the current step in the edit sequence
 | ||||||
|         const editStep = this.editSteps[currentIndex] || {}; |         const editStep = editSteps[currentIndex] || {}; | ||||||
|         const { name } = editStep; |         const { name } = editStep; | ||||||
| 
 | 
 | ||||||
|         if (name) { |         if (name) { | ||||||
| @ -156,6 +156,7 @@ export default Component.extend({ | |||||||
|               if (typeof nextAction === 'function') { |               if (typeof nextAction === 'function') { | ||||||
|                 return (previousAction = nextAction); |                 return (previousAction = nextAction); | ||||||
|               } |               } | ||||||
|  |               // otherwise clear the previous action
 | ||||||
|               previousAction = noop; |               previousAction = noop; | ||||||
|             }) |             }) | ||||||
|             .catch(() => { |             .catch(() => { | ||||||
| @ -228,9 +229,13 @@ export default Component.extend({ | |||||||
| 
 | 
 | ||||||
|   didReceiveAttrs() { |   didReceiveAttrs() { | ||||||
|     this._super(...Array.from(arguments)); |     this._super(...Array.from(arguments)); | ||||||
|     this.resetEdit(); |  | ||||||
|     // Perform validation step on the received component attributes
 |     // Perform validation step on the received component attributes
 | ||||||
|     this.validateAttrs(); |     this.validateAttrs(); | ||||||
|  | 
 | ||||||
|  |     // Set the current step to first edit step if compliance policy is new / doesn't exist
 | ||||||
|  |     if (get(this, 'isNewComplianceInfo')) { | ||||||
|  |       this.updateStep(0); | ||||||
|  |     } | ||||||
|   }, |   }, | ||||||
| 
 | 
 | ||||||
|   /** |   /** | ||||||
| @ -302,13 +307,6 @@ export default Component.extend({ | |||||||
|     this.disableDomCloaking(); |     this.disableDomCloaking(); | ||||||
|   }, |   }, | ||||||
| 
 | 
 | ||||||
|   /** |  | ||||||
|    * Resets the editable state of the component, dependent on `isNewComplianceInfo` flag |  | ||||||
|    */ |  | ||||||
|   resetEdit() { |  | ||||||
|     return get(this, 'isNewComplianceInfo') ? this.updateStep(0) : this.updateStep(initialStepIndex); |  | ||||||
|   }, |  | ||||||
| 
 |  | ||||||
|   /** |   /** | ||||||
|    * Ensure that props received from on this component |    * Ensure that props received from on this component | ||||||
|    * are valid, otherwise flag |    * are valid, otherwise flag | ||||||
| @ -331,11 +329,8 @@ export default Component.extend({ | |||||||
|   // Map generic logical type to options consumable in DOM
 |   // Map generic logical type to options consumable in DOM
 | ||||||
|   genericLogicalTypes: logicalTypesForGeneric, |   genericLogicalTypes: logicalTypesForGeneric, | ||||||
| 
 | 
 | ||||||
|   // Map classifiers to options better consumed in DOM
 |   // Map of classifiers options for drop down
 | ||||||
|   classifiers: ['', ...classifiers.sort()].map(value => ({ |   classifiers: securityClassificationDropdownOptions, | ||||||
|     value, |  | ||||||
|     label: value ? formatAsCapitalizedStringWithSpaces(value) : '...' |  | ||||||
|   })), |  | ||||||
| 
 | 
 | ||||||
|   /** |   /** | ||||||
|    * @type {Boolean} cached boolean flag indicating that fields do contain a `kafka type` |    * @type {Boolean} cached boolean flag indicating that fields do contain a `kafka type` | ||||||
| @ -1049,6 +1044,25 @@ export default Component.extend({ | |||||||
|       return set(this, 'complianceInfo.complianceType', purgePolicy); |       return set(this, 'complianceInfo.complianceType', purgePolicy); | ||||||
|     }, |     }, | ||||||
| 
 | 
 | ||||||
|  |     /** | ||||||
|  |      * Updates the policy flag indicating that this dataset contains personal data | ||||||
|  |      * @param {boolean} containsPersonalData | ||||||
|  |      * @returns boolean | ||||||
|  |      */ | ||||||
|  |     onDatasetLevelPolicyChange(containsPersonalData) { | ||||||
|  |       // directly mutate the attribute on the complianceInfo object
 | ||||||
|  |       return set(this, 'complianceInfo.containingPersonalData', containsPersonalData); | ||||||
|  |     }, | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * Updates the confidentiality flag on the dataset compliance | ||||||
|  |      * @param {null | Classification} [securityClassification=null]  | ||||||
|  |      * @returns null | Classification | ||||||
|  |      */ | ||||||
|  |     onDatasetSecurityClassificationChange(securityClassification = null) { | ||||||
|  |       return set(this, 'complianceInfo.confidentiality', securityClassification); | ||||||
|  |     }, | ||||||
|  | 
 | ||||||
|     /** |     /** | ||||||
|      * If all validity checks are passed, invoke onSave action on controller |      * If all validity checks are passed, invoke onSave action on controller | ||||||
|      */ |      */ | ||||||
|  | |||||||
							
								
								
									
										74
									
								
								wherehows-web/app/components/datasets/schemaless-tagging.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										74
									
								
								wherehows-web/app/components/datasets/schemaless-tagging.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,74 @@ | |||||||
|  | import Component from '@ember/component'; | ||||||
|  | import { get } from '@ember/object'; | ||||||
|  | import { | ||||||
|  |   Classification, | ||||||
|  |   ISecurityClassificationOption, | ||||||
|  |   securityClassificationDropdownOptions | ||||||
|  | } from 'wherehows-web/constants'; | ||||||
|  | 
 | ||||||
|  | type NullOrClassification = null | Classification; | ||||||
|  | 
 | ||||||
|  | export default class SchemalessTagging extends Component { | ||||||
|  |   classNames = ['schemaless-tagging']; | ||||||
|  | 
 | ||||||
|  |   /** | ||||||
|  |    * Interface for parent supplied onPersonalDataChange action | ||||||
|  |    * @memberof SchemalessTagging | ||||||
|  |    */ | ||||||
|  |   onPersonalDataChange: (containsPersonalData: boolean) => boolean; | ||||||
|  | 
 | ||||||
|  |   /** | ||||||
|  |    * Interface for parent supplied onClassificationChange action | ||||||
|  |    * @memberof SchemalessTagging | ||||||
|  |    */ | ||||||
|  |   onClassificationChange: (securityClassification: NullOrClassification) => NullOrClassification; | ||||||
|  | 
 | ||||||
|  |   /** | ||||||
|  |    * Flag indicating that the dataset contains personally identifiable data | ||||||
|  |    * @type {boolean} | ||||||
|  |    * @memberof SchemalessTagging | ||||||
|  |    */ | ||||||
|  |   containsPersonalData: boolean; | ||||||
|  | 
 | ||||||
|  |   /** | ||||||
|  |    * List of drop down options for classifying the dataset | ||||||
|  |    * @type {Array<ISecurityClassificationOption>} | ||||||
|  |    * @memberof SchemalessTagging | ||||||
|  |    */ | ||||||
|  |   classifiers: Array<ISecurityClassificationOption> = securityClassificationDropdownOptions; | ||||||
|  | 
 | ||||||
|  |   /** | ||||||
|  |    * Flag indicating if this component should be in edit mode or readonly | ||||||
|  |    * @type {boolean} | ||||||
|  |    * @memberof SchemalessTagging | ||||||
|  |    */ | ||||||
|  |   isEditable: boolean; | ||||||
|  | 
 | ||||||
|  |   /** | ||||||
|  |    * The current dataset classification value | ||||||
|  |    * @type {(null | Classification)} | ||||||
|  |    * @memberof SchemalessTagging | ||||||
|  |    */ | ||||||
|  |   classification: null | Classification; | ||||||
|  | 
 | ||||||
|  |   actions = { | ||||||
|  |     /** | ||||||
|  |      * Invokes the closure action onPersonaDataChange when the flag is toggled | ||||||
|  |      * @param {boolean} containsPersonalDataTag flag indicating that the dataset contains personal data | ||||||
|  |      * @returns boolean | ||||||
|  |      */ | ||||||
|  |     onPersonalDataToggle(this: SchemalessTagging, containsPersonalDataTag: boolean) { | ||||||
|  |       return get(this, 'onPersonalDataChange')(containsPersonalDataTag); | ||||||
|  |     }, | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * Updates the dataset security classification via the closure action onClassificationChange | ||||||
|  |      * @param {ISecurityClassificationOption} { value } security Classification value for the dataset | ||||||
|  |      * @returns null | Classification | ||||||
|  |      */ | ||||||
|  |     onSecurityClassificationChange(this: SchemalessTagging, { value }: ISecurityClassificationOption) { | ||||||
|  |       const securityClassification = value || null; | ||||||
|  |       return get(this, 'onClassificationChange')(securityClassification); | ||||||
|  |     } | ||||||
|  |   }; | ||||||
|  | } | ||||||
| @ -81,4 +81,22 @@ const complianceSteps = { | |||||||
|   } |   } | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| export { compliancePolicyStrings, fieldIdentifierOptions, complianceSteps, hiddenTrackingFields }; | /** | ||||||
|  |  * Takes a map of dataset options and constructs the relevant compliance edit wizard steps to build the wizard flow | ||||||
|  |  * @param {{ hasSchema: boolean }} [{ hasSchema }={ hasSchema: true }] flag indicating if the dataset is schema-less | ||||||
|  |  * @returns {([x: number]: {name: string})} | ||||||
|  |  */ | ||||||
|  | const getComplianceSteps = ( | ||||||
|  |   { hasSchema }: { hasSchema: boolean } = { hasSchema: true } | ||||||
|  | ): { [x: number]: { name: string } } => { | ||||||
|  |   // Step to tag dataset with PII data, this is at the dataset level for schema-less datasets
 | ||||||
|  |   const piiTaggingStep = { 0: { name: 'editDatasetLevelCompliancePolicy' } }; | ||||||
|  | 
 | ||||||
|  |   if (!hasSchema) { | ||||||
|  |     return { ...complianceSteps, ...piiTaggingStep }; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   return complianceSteps; | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export { compliancePolicyStrings, fieldIdentifierOptions, complianceSteps, hiddenTrackingFields, getComplianceSteps }; | ||||||
|  | |||||||
| @ -1,4 +1,5 @@ | |||||||
| import Ember from 'ember'; | import Ember from 'ember'; | ||||||
|  | import { capitalize } from '@ember/string'; | ||||||
| import { | import { | ||||||
|   Classification, |   Classification, | ||||||
|   nonIdFieldLogicalTypes, |   nonIdFieldLogicalTypes, | ||||||
| @ -11,6 +12,16 @@ import { | |||||||
|   FieldIdValues |   FieldIdValues | ||||||
| } from 'wherehows-web/constants/datasets/compliance'; | } from 'wherehows-web/constants/datasets/compliance'; | ||||||
| 
 | 
 | ||||||
|  | /** | ||||||
|  |  * Defines the interface for an each security classification dropdown option | ||||||
|  |  * @export | ||||||
|  |  * @interface ISecurityClassificationOption | ||||||
|  |  */ | ||||||
|  | export interface ISecurityClassificationOption { | ||||||
|  |   value: '' | Classification; | ||||||
|  |   label: string; | ||||||
|  | } | ||||||
|  | 
 | ||||||
| /** | /** | ||||||
|  * Length of time between suggestion modification time and last modified time for the compliance policy |  * Length of time between suggestion modification time and last modified time for the compliance policy | ||||||
|  * If a policy has been updated within the range of this window then it is considered as stale / or |  * If a policy has been updated within the range of this window then it is considered as stale / or | ||||||
| @ -52,7 +63,7 @@ const nonIdFieldDataTypeClassification: { [K: string]: Classification } = generi | |||||||
| 
 | 
 | ||||||
| /** | /** | ||||||
|  * A merge of id and non id field type security classifications |  * A merge of id and non id field type security classifications | ||||||
|  * @type {[K: string] : Classification} |  * @type {([k: string]: Classification)} | ||||||
|  */ |  */ | ||||||
| const defaultFieldDataTypeClassification = { ...idFieldDataTypeClassification, ...nonIdFieldDataTypeClassification }; | const defaultFieldDataTypeClassification = { ...idFieldDataTypeClassification, ...nonIdFieldDataTypeClassification }; | ||||||
| 
 | 
 | ||||||
| @ -64,6 +75,26 @@ const classifiers = Object.values(defaultFieldDataTypeClassification).filter( | |||||||
|   (classifier, index, iter) => iter.indexOf(classifier) === index |   (classifier, index, iter) => iter.indexOf(classifier) === index | ||||||
| ); | ); | ||||||
| 
 | 
 | ||||||
|  | /** | ||||||
|  |  * Takes a string, returns a formatted string. Niche , single use case | ||||||
|  |  * for now, so no need to make into a helper | ||||||
|  |  * @param {string} string | ||||||
|  |  */ | ||||||
|  | const formatAsCapitalizedStringWithSpaces = (string: string) => | ||||||
|  |   capitalize(string.replace(/[A-Z]/g, match => ` ${match}`)); | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * A derived list of security classification options from classifiers list, including an empty string option and value | ||||||
|  |  * @type {Array<ISecurityClassificationOption>} | ||||||
|  |  */ | ||||||
|  | const securityClassificationDropdownOptions: Array<ISecurityClassificationOption> = [ | ||||||
|  |   '', | ||||||
|  |   ...classifiers.sort() | ||||||
|  | ].map((value: '' | Classification) => ({ | ||||||
|  |   value, | ||||||
|  |   label: value ? formatAsCapitalizedStringWithSpaces(value) : '...' | ||||||
|  | })); | ||||||
|  | 
 | ||||||
| /** | /** | ||||||
|  * Checks if the identifierType is a mixed Id |  * Checks if the identifierType is a mixed Id | ||||||
|  * @param {string} identifierType |  * @param {string} identifierType | ||||||
| @ -101,7 +132,7 @@ const getDefaultLogicalType = (identifierType: string): string | void => { | |||||||
| /** | /** | ||||||
|  * Returns a list of logicalType mappings for displaying its value and a label by logicalType |  * Returns a list of logicalType mappings for displaying its value and a label by logicalType | ||||||
|  * @param {('id' | 'generic')} logicalType  |  * @param {('id' | 'generic')} logicalType  | ||||||
|  * @returns {Array<{value: NonIdLogicalType | IdLogicalType; label: string;}>} |  * @returns {(Array<{ value: NonIdLogicalType | IdLogicalType; label: string }>)}  | ||||||
|  */ |  */ | ||||||
| const logicalTypeValueLabel = (logicalType: 'id' | 'generic') => { | const logicalTypeValueLabel = (logicalType: 'id' | 'generic') => { | ||||||
|   const logicalTypes: Array<NonIdLogicalType | IdLogicalType> = { |   const logicalTypes: Array<NonIdLogicalType | IdLogicalType> = { | ||||||
| @ -129,13 +160,13 @@ const logicalTypeValueLabel = (logicalType: 'id' | 'generic') => { | |||||||
| 
 | 
 | ||||||
| /** | /** | ||||||
|  * Map logicalTypes to options consumable by DOM |  * Map logicalTypes to options consumable by DOM | ||||||
|  * @returns {Array<{value: IdLogicalType; label: string;}>} |  * @returns {(Array<{value: IdLogicalType; label: string;}>)} | ||||||
|  */ |  */ | ||||||
| const logicalTypesForIds = logicalTypeValueLabel('id'); | const logicalTypesForIds = logicalTypeValueLabel('id'); | ||||||
| 
 | 
 | ||||||
| /** | /** | ||||||
|  * Map generic logical type to options consumable in DOM |  * Map generic logical type to options consumable in DOM | ||||||
|  * @returns {Array<{value: NonIdLogicalType; label: string;}>} |  * @returns {(Array<{value: NonIdLogicalType; label: string;}>)} | ||||||
|  */ |  */ | ||||||
| const logicalTypesForGeneric = logicalTypeValueLabel('generic'); | const logicalTypesForGeneric = logicalTypeValueLabel('generic'); | ||||||
| 
 | 
 | ||||||
| @ -155,7 +186,8 @@ const fieldIdentifierTypeValues: Array<FieldIdValues> = Object.values(FieldIdVal | |||||||
| 
 | 
 | ||||||
| export { | export { | ||||||
|   defaultFieldDataTypeClassification, |   defaultFieldDataTypeClassification, | ||||||
|   classifiers, |   securityClassificationDropdownOptions, | ||||||
|  |   formatAsCapitalizedStringWithSpaces, | ||||||
|   fieldIdentifierTypeIds, |   fieldIdentifierTypeIds, | ||||||
|   fieldIdentifierTypeValues, |   fieldIdentifierTypeValues, | ||||||
|   isMixedId, |   isMixedId, | ||||||
|  | |||||||
| @ -108,7 +108,7 @@ export default Route.extend({ | |||||||
|           let properties; |           let properties; | ||||||
| 
 | 
 | ||||||
|           const [ |           const [ | ||||||
|             columns, |             { schemaless, columns }, | ||||||
|             compliance, |             compliance, | ||||||
|             complianceSuggestion, |             complianceSuggestion, | ||||||
|             datasetComments, |             datasetComments, | ||||||
| @ -140,6 +140,7 @@ export default Route.extend({ | |||||||
|             isNewComplianceInfo, |             isNewComplianceInfo, | ||||||
|             complianceSuggestion, |             complianceSuggestion, | ||||||
|             datasetComments, |             datasetComments, | ||||||
|  |             schemaless, | ||||||
|             schemas, |             schemas, | ||||||
|             isInternal, |             isInternal, | ||||||
|             datasetView, |             datasetView, | ||||||
|  | |||||||
| @ -52,3 +52,8 @@ | |||||||
| .hidden { | .hidden { | ||||||
|   display: none; |   display: none; | ||||||
| } | } | ||||||
|  | 
 | ||||||
|  | /// Adds a visual indicator for text that have an associated help text | ||||||
|  | .define-text { | ||||||
|  |   border-bottom: 1px dashed #999; | ||||||
|  | } | ||||||
|  | |||||||
| @ -15,6 +15,8 @@ | |||||||
| @import 'dataset-property/all'; | @import 'dataset-property/all'; | ||||||
| @import 'user-lookup/all'; | @import 'user-lookup/all'; | ||||||
| @import 'pendulum-ellipsis-animation/all'; | @import 'pendulum-ellipsis-animation/all'; | ||||||
|  | @import 'toggle-switch/all'; | ||||||
|  | @import 'schemaless-tagging/all'; | ||||||
| 
 | 
 | ||||||
| @import 'nacho/nacho-button'; | @import 'nacho/nacho-button'; | ||||||
| @import 'nacho/nacho-global-search'; | @import 'nacho/nacho-global-search'; | ||||||
|  | |||||||
| @ -2,7 +2,7 @@ | |||||||
|  * Prompt for privacy compliance |  * Prompt for privacy compliance | ||||||
|  */ |  */ | ||||||
| .metadata-prompt { | .metadata-prompt { | ||||||
|   margin: 30px 0 20px; |   margin: 40px 0 10px; | ||||||
| 
 | 
 | ||||||
|   /** |   /** | ||||||
|    * Overrides default styles |    * Overrides default styles | ||||||
|  | |||||||
| @ -0,0 +1 @@ | |||||||
|  | @import 'schemaless-tagging'; | ||||||
| @ -0,0 +1,15 @@ | |||||||
|  | .schemaless-tagging { | ||||||
|  |   &__tag { | ||||||
|  |     display: flex; | ||||||
|  |     align-items: center; | ||||||
|  |     justify-content: space-between; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   &__prompt { | ||||||
|  |     line-height: 1.5; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   &__input { | ||||||
|  |     margin-right: 40%; | ||||||
|  |   } | ||||||
|  | } | ||||||
| @ -0,0 +1 @@ | |||||||
|  | @import 'toggle-switch'; | ||||||
| @ -0,0 +1,58 @@ | |||||||
|  | .toggle-switch { | ||||||
|  |   display: none; | ||||||
|  | 
 | ||||||
|  |   + .toggle-button { | ||||||
|  |     outline: 0; | ||||||
|  |     display: block; | ||||||
|  |     width: 4em; | ||||||
|  |     height: 2em; | ||||||
|  |     position: relative; | ||||||
|  |     cursor: pointer; | ||||||
|  |     user-select: none; | ||||||
|  |     margin: 0; | ||||||
|  | 
 | ||||||
|  |     &::selection { | ||||||
|  |       background: none; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     &::after, | ||||||
|  |     &::before { | ||||||
|  |       position: relative; | ||||||
|  |       display: block; | ||||||
|  |       content: ''; | ||||||
|  |       width: 50%; | ||||||
|  |       height: 100%; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     &::after { | ||||||
|  |       left: 0; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     &::before { | ||||||
|  |       display: none; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   &:checked + .toggle-button::after { | ||||||
|  |     left: 50%; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   &--light { | ||||||
|  |     + .toggle-button { | ||||||
|  |       background: #f0f0f0; | ||||||
|  |       border-radius: 2em; | ||||||
|  |       padding: 2px; | ||||||
|  |       transition: all 0.4s ease; | ||||||
|  | 
 | ||||||
|  |       &::after { | ||||||
|  |         border-radius: 50%; | ||||||
|  |         background: set-color(white, base); | ||||||
|  |         transition: all 0.2s ease; | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     &:checked + .toggle-button { | ||||||
|  |       background: set-color(green, green5); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | } | ||||||
| @ -90,9 +90,11 @@ | |||||||
|     </div> |     </div> | ||||||
|   {{/if}} |   {{/if}} | ||||||
| 
 | 
 | ||||||
|   {{#if (and (eq editStepIndex 0) (not _hasBadData))}} |   {{#unless schemaless}} | ||||||
|     {{json-upload receiveJsonFile=(action "onComplianceJsonUpload") class="secondary-actions__action"}} |     {{#if (and (eq editStepIndex 0) (not _hasBadData))}} | ||||||
|   {{/if}} |       {{json-upload receiveJsonFile=(action "onComplianceJsonUpload") class="secondary-actions__action"}} | ||||||
|  |     {{/if}} | ||||||
|  |   {{/unless}} | ||||||
| 
 | 
 | ||||||
|   {{#if (or isReadOnly (eq editStepIndex 2))}} |   {{#if (or isReadOnly (eq editStepIndex 2))}} | ||||||
|     {{partial "datasets/dataset-compliance/dataset-classification"}} |     {{partial "datasets/dataset-compliance/dataset-classification"}} | ||||||
| @ -108,10 +110,25 @@ | |||||||
|     }} |     }} | ||||||
|   {{/if}} |   {{/if}} | ||||||
| 
 | 
 | ||||||
|   {{#if (or isReadOnly (eq editStepIndex 0))}} |   {{#if schemaless}} | ||||||
|     {{partial "datasets/dataset-compliance/dataset-compliance-entities"}} |  | ||||||
|   {{/if}} |  | ||||||
| 
 | 
 | ||||||
|  |     {{#if (or isReadOnly (eq editStepIndex 0))}} | ||||||
|  |       {{datasets/schemaless-tagging | ||||||
|  |         isEditable=(not isReadOnly) | ||||||
|  |         classification=(readonly complianceInfo.confidentiality) | ||||||
|  |         containsPersonalData=(readonly complianceInfo.containingPersonalData) | ||||||
|  |         onClassificationChange=(action "onDatasetSecurityClassificationChange") | ||||||
|  |         onPersonalDataChange=(action "onDatasetLevelPolicyChange") | ||||||
|  |       }} | ||||||
|  |     {{/if}} | ||||||
|  | 
 | ||||||
|  |   {{else}} | ||||||
|  | 
 | ||||||
|  |     {{#if (or isReadOnly (eq editStepIndex 0))}} | ||||||
|  |       {{partial "datasets/dataset-compliance/dataset-compliance-entities"}} | ||||||
|  |     {{/if}} | ||||||
|  | 
 | ||||||
|  |   {{/if}} | ||||||
| </div> | </div> | ||||||
| 
 | 
 | ||||||
| {{yield}} | {{yield}} | ||||||
|  | |||||||
| @ -0,0 +1,41 @@ | |||||||
|  | <section class="metadata-prompt"> | ||||||
|  |   <header class="metadata-prompt__header"> | ||||||
|  |     <p> | ||||||
|  |       Dataset <span title="Personally Identifiable Information" class="define-text">PII</span> & Security Classification | ||||||
|  |     </p> | ||||||
|  |   </header> | ||||||
|  | </section> | ||||||
|  | 
 | ||||||
|  | <div class="schemaless-tagging__tag"> | ||||||
|  |   <h4 class="schemaless-tagging__prompt"> | ||||||
|  |     Dataset contains personally identifiable information? | ||||||
|  |   </h4> | ||||||
|  | 
 | ||||||
|  |   <div class="schemaless-tagging__input"> | ||||||
|  |     {{input | ||||||
|  |       id=(concat elementId '-schemaless-checkbox') | ||||||
|  |       type="checkbox" | ||||||
|  |       class="toggle-switch toggle-switch--light" | ||||||
|  |       disabled=(not isEditable) | ||||||
|  |       checked=(readonly containsPersonalData) | ||||||
|  |       change=(action "onPersonalDataToggle" value="target.checked") | ||||||
|  |     }} | ||||||
|  |     <label for="{{concat elementId '-schemaless-checkbox'}}" class="toggle-button"> | ||||||
|  |     </label> | ||||||
|  |   </div> | ||||||
|  | </div> | ||||||
|  | 
 | ||||||
|  | <div class="schemaless-tagging__tag"> | ||||||
|  |   <h4 class="schemaless-tagging__prompt"> | ||||||
|  |     Dataset security classification | ||||||
|  |   </h4> | ||||||
|  | 
 | ||||||
|  |   <div class="schemaless-tagging__input"> | ||||||
|  |     {{ember-selector | ||||||
|  |       values=classifiers | ||||||
|  |       selected=classification | ||||||
|  |       disabled=(not isEditable) | ||||||
|  |       selectionDidChange=(action "onSecurityClassificationChange") | ||||||
|  |     }} | ||||||
|  |   </div> | ||||||
|  | </div> | ||||||
| @ -268,6 +268,7 @@ | |||||||
|     <div id="compliancetab" class="tab-pane"> |     <div id="compliancetab" class="tab-pane"> | ||||||
|       {{dataset-compliance |       {{dataset-compliance | ||||||
|         datasetName=model.name |         datasetName=model.name | ||||||
|  |         schemaless=schemaless | ||||||
|         platform=datasetView.platform |         platform=datasetView.platform | ||||||
|         complianceInfo=complianceInfo |         complianceInfo=complianceInfo | ||||||
|         complianceSuggestion=complianceSuggestion |         complianceSuggestion=complianceSuggestion | ||||||
|  | |||||||
| @ -31,8 +31,9 @@ interface IDatasetColumnWithHtmlComments extends IDatasetColumn { | |||||||
|  */ |  */ | ||||||
| interface IDatasetColumnsGetResponse { | interface IDatasetColumnsGetResponse { | ||||||
|   status: ApiStatus; |   status: ApiStatus; | ||||||
|   columns?: Array<IDatasetColumn> | null; |   columns?: Array<IDatasetColumn>; | ||||||
|   message?: string; |   message?: string; | ||||||
|  |   schemaless: boolean; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export { IDatasetColumn, IDatasetColumnWithHtmlComments, IDatasetColumnsGetResponse }; | export { IDatasetColumn, IDatasetColumnWithHtmlComments, IDatasetColumnsGetResponse }; | ||||||
|  | |||||||
| @ -64,18 +64,20 @@ const augmentObjectsWithHtmlComments = arrayMap(augmentWithHtmlComment); | |||||||
| const columnDataTypesAndFieldNames = arrayMap(columnDataTypeAndFieldName); | const columnDataTypesAndFieldNames = arrayMap(columnDataTypeAndFieldName); | ||||||
| 
 | 
 | ||||||
| /** | /** | ||||||
|  * Gets the dataset columns for a dataset with the id specified |  * Gets the dataset columns for a dataset with the id specified and the schemaless flag | ||||||
|  * @param {number} id the id of the dataset |  * @param {number} id the id of the dataset | ||||||
|  * @return {Promise<Array<IDatasetColumn>>} |  * @return {(Promise<{schemaless: boolean; columns: Array<IDatasetColumn>}>)} | ||||||
|  */ |  */ | ||||||
| const readDatasetColumns = async (id: number): Promise<Array<IDatasetColumn>> => { | const readDatasetColumns = async (id: number): Promise<{ schemaless: boolean; columns: Array<IDatasetColumn> }> => { | ||||||
|   const { status, columns, message = datasetColumnsException } = await getJSON<IDatasetColumnsGetResponse>({ |   const { status, columns = [], schemaless, message = datasetColumnsException } = await getJSON< | ||||||
|  |     IDatasetColumnsGetResponse | ||||||
|  |   >({ | ||||||
|     url: datasetColumnUrlById(id) |     url: datasetColumnUrlById(id) | ||||||
|   }); |   }); | ||||||
| 
 | 
 | ||||||
|   // Returns an empty list if the status is ok but the columns is falsey
 |   // Returns an empty list if the status is ok but the columns is falsey
 | ||||||
|   if (status === ApiStatus.OK) { |   if (status === ApiStatus.OK) { | ||||||
|     return columns || []; |     return { schemaless, columns }; | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   throw new Error(message); |   throw new Error(message); | ||||||
|  | |||||||
| @ -0,0 +1,94 @@ | |||||||
|  | import { moduleForComponent, test } from 'ember-qunit'; | ||||||
|  | import hbs from 'htmlbars-inline-precompile'; | ||||||
|  | 
 | ||||||
|  | import { Classification } from 'wherehows-web/constants'; | ||||||
|  | import { triggerEvent } from 'ember-native-dom-helpers'; | ||||||
|  | 
 | ||||||
|  | moduleForComponent('datasets/schemaless-tagging', 'Integration | Component | datasets/schemaless tagging', { | ||||||
|  |   integration: true | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | test('it renders', function(assert) { | ||||||
|  |   assert.expect(2); | ||||||
|  |   const elementId = 'test-schemaless-component-1337'; | ||||||
|  |   this.set('elementId', elementId); | ||||||
|  |   this.render(hbs`{{datasets/schemaless-tagging elementId=elementId}}`); | ||||||
|  | 
 | ||||||
|  |   assert.ok(document.querySelector(`#${elementId}-schemaless-checkbox`), 'it renders a checkbox component'); | ||||||
|  |   assert.ok(document.querySelector(`#${elementId} select`), 'it renders a select drop down'); | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | test('it shows the current classification', function(assert) { | ||||||
|  |   assert.expect(3); | ||||||
|  |   this.render(hbs`{{datasets/schemaless-tagging classification=classification}}`); | ||||||
|  | 
 | ||||||
|  |   assert.equal(document.querySelector(`select`).value, '', "displays '' when not set"); | ||||||
|  | 
 | ||||||
|  |   this.set('classification', Classification.LimitedDistribution); | ||||||
|  | 
 | ||||||
|  |   assert.equal( | ||||||
|  |     document.querySelector(`select`).value, | ||||||
|  |     Classification.LimitedDistribution, | ||||||
|  |     `displays ${Classification.LimitedDistribution} when set` | ||||||
|  |   ); | ||||||
|  | 
 | ||||||
|  |   this.set('classification', Classification.Confidential); | ||||||
|  | 
 | ||||||
|  |   assert.equal( | ||||||
|  |     document.querySelector('select').value, | ||||||
|  |     Classification.Confidential, | ||||||
|  |     `displays ${Classification.Confidential} when changed` | ||||||
|  |   ); | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | test('it correctly indicates if the dataset has pii', function(assert) { | ||||||
|  |   assert.expect(2); | ||||||
|  |   this.set('containsPersonalData', true); | ||||||
|  | 
 | ||||||
|  |   this.render(hbs`{{datasets/schemaless-tagging containsPersonalData=containsPersonalData}}`); | ||||||
|  | 
 | ||||||
|  |   assert.equal(document.querySelector('.toggle-switch').checked, true, 'checkbox is checked when true'); | ||||||
|  | 
 | ||||||
|  |   this.set('containsPersonalData', false); | ||||||
|  | 
 | ||||||
|  |   assert.notOk(document.querySelector('.toggle-switch').checked, 'checkbox is unchecked when false'); | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | test('it invokes the onClassificationChange external action when change is triggered', function(assert) { | ||||||
|  |   assert.expect(2); | ||||||
|  |   let onClassificationChangeCallCount = 0; | ||||||
|  | 
 | ||||||
|  |   this.set('isEditable', true); | ||||||
|  |   this.set('classification', Classification.LimitedDistribution); | ||||||
|  |   this.set('onClassificationChange', () => { | ||||||
|  |     assert.equal(++onClassificationChangeCallCount, 1, 'successfully invokes the external action'); | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   this.render( | ||||||
|  |     hbs`{{datasets/schemaless-tagging isEditable=isEditable onClassificationChange=onClassificationChange classification=classification}}` | ||||||
|  |   ); | ||||||
|  | 
 | ||||||
|  |   assert.equal(onClassificationChangeCallCount, 0, 'external action is not invoked on instantiation'); | ||||||
|  | 
 | ||||||
|  |   triggerEvent('select', 'change'); | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | test('it invokes the onPersonalDataChange external action on when toggled', function(assert) { | ||||||
|  |   assert.expect(3); | ||||||
|  | 
 | ||||||
|  |   let onPersonalDataChangeCallCount = 0; | ||||||
|  | 
 | ||||||
|  |   this.set('isEditable', true); | ||||||
|  |   this.set('containsPersonalData', false); | ||||||
|  |   this.set('onPersonalDataChange', containsPersonalData => { | ||||||
|  |     assert.equal(++onPersonalDataChangeCallCount, 1, 'successfully invokes the external action'); | ||||||
|  |     assert.ok(containsPersonalData, 'flag value is truthy'); | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   this.render( | ||||||
|  |     hbs`{{datasets/schemaless-tagging isEditable=isEditable onPersonalDataChange=onPersonalDataChange containsPersonalData=containsPersonalData}}` | ||||||
|  |   ); | ||||||
|  | 
 | ||||||
|  |   assert.equal(onPersonalDataChangeCallCount, 0, 'external action is not invoked on instantiation'); | ||||||
|  |   triggerEvent('[type=checkbox]', 'click'); | ||||||
|  | }); | ||||||
| @ -0,0 +1,22 @@ | |||||||
|  | import { module, test } from 'qunit'; | ||||||
|  | import { getComplianceSteps, complianceSteps } from 'wherehows-web/constants'; | ||||||
|  | 
 | ||||||
|  | module('Unit | Constants | dataset compliance'); | ||||||
|  | 
 | ||||||
|  | test('getComplianceSteps function should behave as expected', function(assert) { | ||||||
|  |   assert.expect(3); | ||||||
|  |   const piiTaggingStep = { 0: { name: 'editDatasetLevelCompliancePolicy' } }; | ||||||
|  |   let result; | ||||||
|  | 
 | ||||||
|  |   assert.equal(typeof getComplianceSteps, 'function', 'getComplianceSteps is a function'); | ||||||
|  |   result = getComplianceSteps(); | ||||||
|  | 
 | ||||||
|  |   assert.deepEqual(result, complianceSteps, 'getComplianceSteps result is expected shape when no args are passed'); | ||||||
|  | 
 | ||||||
|  |   result = getComplianceSteps({ hasSchema: false }); | ||||||
|  |   assert.deepEqual( | ||||||
|  |     result, | ||||||
|  |     { ...complianceSteps, ...piiTaggingStep }, | ||||||
|  |     'getComplianceSteps result is expected shape when hasSchema attribute is false' | ||||||
|  |   ); | ||||||
|  | }); | ||||||
| @ -2,7 +2,8 @@ import { | |||||||
|   lastSeenSuggestionInterval, |   lastSeenSuggestionInterval, | ||||||
|   lowQualitySuggestionConfidenceThreshold, |   lowQualitySuggestionConfidenceThreshold, | ||||||
|   defaultFieldDataTypeClassification, |   defaultFieldDataTypeClassification, | ||||||
|   logicalTypeValueLabel |   logicalTypeValueLabel, | ||||||
|  |   formatAsCapitalizedStringWithSpaces | ||||||
| } from 'wherehows-web/constants/metadata-acquisition'; | } from 'wherehows-web/constants/metadata-acquisition'; | ||||||
| import { | import { | ||||||
|   Classification, |   Classification, | ||||||
| @ -81,3 +82,13 @@ test('logicalTypeValueLabel generates correct labels for generic type', function | |||||||
|     assert.ok(idFieldLogicalTypeValues.includes(value), `Value ${value} found in ${idFieldLogicalTypeValues}`); |     assert.ok(idFieldLogicalTypeValues.includes(value), `Value ${value} found in ${idFieldLogicalTypeValues}`); | ||||||
|   }); |   }); | ||||||
| }); | }); | ||||||
|  | 
 | ||||||
|  | test('formatAsCapitalizedStringWithSpaces generates the correct display string', function(assert) { | ||||||
|  |   [ | ||||||
|  |     ['confidential', 'Confidential'], | ||||||
|  |     ['limitedDistribution', 'Limited Distribution'], | ||||||
|  |     ['highlyConfidential', 'Highly Confidential'] | ||||||
|  |   ].forEach(([source, target]) => { | ||||||
|  |     assert.equal(formatAsCapitalizedStringWithSpaces(source), target, `correctly converts ${source}`); | ||||||
|  |   }); | ||||||
|  | }); | ||||||
|  | |||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user
	 Seyi Adebajo
						Seyi Adebajo