mirror of
				https://github.com/datahub-project/datahub.git
				synced 2025-10-31 02:37:05 +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 isTrackingHeaderField from 'wherehows-web/utils/validators/tracking-headers'; | ||||
| import { | ||||
|   classifiers, | ||||
|   securityClassificationDropdownOptions, | ||||
|   DatasetClassifiers, | ||||
|   fieldIdentifierTypes, | ||||
|   fieldIdentifierOptions, | ||||
| @ -14,7 +14,7 @@ import { | ||||
|   logicalTypesForGeneric, | ||||
|   hasPredefinedFieldFormat, | ||||
|   getDefaultLogicalType, | ||||
|   complianceSteps, | ||||
|   getComplianceSteps, | ||||
|   hiddenTrackingFields, | ||||
|   isExempt | ||||
| } from 'wherehows-web/constants'; | ||||
| @ -53,13 +53,6 @@ const { | ||||
|   invalidPolicyData | ||||
| } = 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 | ||||
|  * @type {Array} | ||||
| @ -109,13 +102,20 @@ export default Component.extend({ | ||||
|    * @type {number} | ||||
|    */ | ||||
|   editStepIndex: initialStepIndex, | ||||
| 
 | ||||
|   /** | ||||
|    * Converts the hash of complianceSteps to a list of steps | ||||
|    * @type {Array<{}>} | ||||
|    * @type {ComputedProperty<Array<{}>>} | ||||
|    */ | ||||
|   editSteps: Object.keys(complianceSteps) | ||||
|     .sort() | ||||
|     .map(key => complianceSteps[key]), | ||||
|   editSteps: computed('schemaless', function() { | ||||
|     const hasSchema = !getWithDefault(this, 'schemaless', false); | ||||
|     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 | ||||
| @ -136,9 +136,9 @@ export default Component.extend({ | ||||
|       let lastIndex = initialStepIndex; | ||||
| 
 | ||||
|       return function() { | ||||
|         const currentIndex = get(this, 'editStepIndex'); | ||||
|         const { editStepIndex: currentIndex, editSteps } = getProperties(this, ['editStepIndex', 'editSteps']); | ||||
|         // the current step in the edit sequence
 | ||||
|         const editStep = this.editSteps[currentIndex] || {}; | ||||
|         const editStep = editSteps[currentIndex] || {}; | ||||
|         const { name } = editStep; | ||||
| 
 | ||||
|         if (name) { | ||||
| @ -156,6 +156,7 @@ export default Component.extend({ | ||||
|               if (typeof nextAction === 'function') { | ||||
|                 return (previousAction = nextAction); | ||||
|               } | ||||
|               // otherwise clear the previous action
 | ||||
|               previousAction = noop; | ||||
|             }) | ||||
|             .catch(() => { | ||||
| @ -228,9 +229,13 @@ export default Component.extend({ | ||||
| 
 | ||||
|   didReceiveAttrs() { | ||||
|     this._super(...Array.from(arguments)); | ||||
|     this.resetEdit(); | ||||
|     // Perform validation step on the received component attributes
 | ||||
|     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(); | ||||
|   }, | ||||
| 
 | ||||
|   /** | ||||
|    * 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 | ||||
|    * are valid, otherwise flag | ||||
| @ -331,11 +329,8 @@ export default Component.extend({ | ||||
|   // Map generic logical type to options consumable in DOM
 | ||||
|   genericLogicalTypes: logicalTypesForGeneric, | ||||
| 
 | ||||
|   // Map classifiers to options better consumed in DOM
 | ||||
|   classifiers: ['', ...classifiers.sort()].map(value => ({ | ||||
|     value, | ||||
|     label: value ? formatAsCapitalizedStringWithSpaces(value) : '...' | ||||
|   })), | ||||
|   // Map of classifiers options for drop down
 | ||||
|   classifiers: securityClassificationDropdownOptions, | ||||
| 
 | ||||
|   /** | ||||
|    * @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); | ||||
|     }, | ||||
| 
 | ||||
|     /** | ||||
|      * 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 | ||||
|      */ | ||||
|  | ||||
							
								
								
									
										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 { capitalize } from '@ember/string'; | ||||
| import { | ||||
|   Classification, | ||||
|   nonIdFieldLogicalTypes, | ||||
| @ -11,6 +12,16 @@ import { | ||||
|   FieldIdValues | ||||
| } 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 | ||||
|  * 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 | ||||
|  * @type {[K: string] : Classification} | ||||
|  * @type {([k: string]: Classification)} | ||||
|  */ | ||||
| const defaultFieldDataTypeClassification = { ...idFieldDataTypeClassification, ...nonIdFieldDataTypeClassification }; | ||||
| 
 | ||||
| @ -64,6 +75,26 @@ const classifiers = Object.values(defaultFieldDataTypeClassification).filter( | ||||
|   (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 | ||||
|  * @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 | ||||
|  * @param {('id' | 'generic')} logicalType  | ||||
|  * @returns {Array<{value: NonIdLogicalType | IdLogicalType; label: string;}>} | ||||
|  * @returns {(Array<{ value: NonIdLogicalType | IdLogicalType; label: string }>)}  | ||||
|  */ | ||||
| const logicalTypeValueLabel = (logicalType: 'id' | 'generic') => { | ||||
|   const logicalTypes: Array<NonIdLogicalType | IdLogicalType> = { | ||||
| @ -129,13 +160,13 @@ const logicalTypeValueLabel = (logicalType: 'id' | 'generic') => { | ||||
| 
 | ||||
| /** | ||||
|  * Map logicalTypes to options consumable by DOM | ||||
|  * @returns {Array<{value: IdLogicalType; label: string;}>} | ||||
|  * @returns {(Array<{value: IdLogicalType; label: string;}>)} | ||||
|  */ | ||||
| const logicalTypesForIds = logicalTypeValueLabel('id'); | ||||
| 
 | ||||
| /** | ||||
|  * 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'); | ||||
| 
 | ||||
| @ -155,7 +186,8 @@ const fieldIdentifierTypeValues: Array<FieldIdValues> = Object.values(FieldIdVal | ||||
| 
 | ||||
| export { | ||||
|   defaultFieldDataTypeClassification, | ||||
|   classifiers, | ||||
|   securityClassificationDropdownOptions, | ||||
|   formatAsCapitalizedStringWithSpaces, | ||||
|   fieldIdentifierTypeIds, | ||||
|   fieldIdentifierTypeValues, | ||||
|   isMixedId, | ||||
|  | ||||
| @ -108,7 +108,7 @@ export default Route.extend({ | ||||
|           let properties; | ||||
| 
 | ||||
|           const [ | ||||
|             columns, | ||||
|             { schemaless, columns }, | ||||
|             compliance, | ||||
|             complianceSuggestion, | ||||
|             datasetComments, | ||||
| @ -140,6 +140,7 @@ export default Route.extend({ | ||||
|             isNewComplianceInfo, | ||||
|             complianceSuggestion, | ||||
|             datasetComments, | ||||
|             schemaless, | ||||
|             schemas, | ||||
|             isInternal, | ||||
|             datasetView, | ||||
|  | ||||
| @ -52,3 +52,8 @@ | ||||
| .hidden { | ||||
|   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 'user-lookup/all'; | ||||
| @import 'pendulum-ellipsis-animation/all'; | ||||
| @import 'toggle-switch/all'; | ||||
| @import 'schemaless-tagging/all'; | ||||
| 
 | ||||
| @import 'nacho/nacho-button'; | ||||
| @import 'nacho/nacho-global-search'; | ||||
|  | ||||
| @ -2,7 +2,7 @@ | ||||
|  * Prompt for privacy compliance | ||||
|  */ | ||||
| .metadata-prompt { | ||||
|   margin: 30px 0 20px; | ||||
|   margin: 40px 0 10px; | ||||
| 
 | ||||
|   /** | ||||
|    * 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> | ||||
|   {{/if}} | ||||
| 
 | ||||
|   {{#if (and (eq editStepIndex 0) (not _hasBadData))}} | ||||
|     {{json-upload receiveJsonFile=(action "onComplianceJsonUpload") class="secondary-actions__action"}} | ||||
|   {{/if}} | ||||
|   {{#unless schemaless}} | ||||
|     {{#if (and (eq editStepIndex 0) (not _hasBadData))}} | ||||
|       {{json-upload receiveJsonFile=(action "onComplianceJsonUpload") class="secondary-actions__action"}} | ||||
|     {{/if}} | ||||
|   {{/unless}} | ||||
| 
 | ||||
|   {{#if (or isReadOnly (eq editStepIndex 2))}} | ||||
|     {{partial "datasets/dataset-compliance/dataset-classification"}} | ||||
| @ -108,10 +110,25 @@ | ||||
|     }} | ||||
|   {{/if}} | ||||
| 
 | ||||
|   {{#if (or isReadOnly (eq editStepIndex 0))}} | ||||
|     {{partial "datasets/dataset-compliance/dataset-compliance-entities"}} | ||||
|   {{/if}} | ||||
|   {{#if schemaless}} | ||||
| 
 | ||||
|     {{#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> | ||||
| 
 | ||||
| {{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"> | ||||
|       {{dataset-compliance | ||||
|         datasetName=model.name | ||||
|         schemaless=schemaless | ||||
|         platform=datasetView.platform | ||||
|         complianceInfo=complianceInfo | ||||
|         complianceSuggestion=complianceSuggestion | ||||
|  | ||||
| @ -31,8 +31,9 @@ interface IDatasetColumnWithHtmlComments extends IDatasetColumn { | ||||
|  */ | ||||
| interface IDatasetColumnsGetResponse { | ||||
|   status: ApiStatus; | ||||
|   columns?: Array<IDatasetColumn> | null; | ||||
|   columns?: Array<IDatasetColumn>; | ||||
|   message?: string; | ||||
|   schemaless: boolean; | ||||
| } | ||||
| 
 | ||||
| export { IDatasetColumn, IDatasetColumnWithHtmlComments, IDatasetColumnsGetResponse }; | ||||
|  | ||||
| @ -64,18 +64,20 @@ const augmentObjectsWithHtmlComments = arrayMap(augmentWithHtmlComment); | ||||
| 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 | ||||
|  * @return {Promise<Array<IDatasetColumn>>} | ||||
|  * @return {(Promise<{schemaless: boolean; columns: Array<IDatasetColumn>}>)} | ||||
|  */ | ||||
| const readDatasetColumns = async (id: number): Promise<Array<IDatasetColumn>> => { | ||||
|   const { status, columns, message = datasetColumnsException } = await getJSON<IDatasetColumnsGetResponse>({ | ||||
| const readDatasetColumns = async (id: number): Promise<{ schemaless: boolean; columns: Array<IDatasetColumn> }> => { | ||||
|   const { status, columns = [], schemaless, message = datasetColumnsException } = await getJSON< | ||||
|     IDatasetColumnsGetResponse | ||||
|   >({ | ||||
|     url: datasetColumnUrlById(id) | ||||
|   }); | ||||
| 
 | ||||
|   // Returns an empty list if the status is ok but the columns is falsey
 | ||||
|   if (status === ApiStatus.OK) { | ||||
|     return columns || []; | ||||
|     return { schemaless, columns }; | ||||
|   } | ||||
| 
 | ||||
|   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, | ||||
|   lowQualitySuggestionConfidenceThreshold, | ||||
|   defaultFieldDataTypeClassification, | ||||
|   logicalTypeValueLabel | ||||
|   logicalTypeValueLabel, | ||||
|   formatAsCapitalizedStringWithSpaces | ||||
| } from 'wherehows-web/constants/metadata-acquisition'; | ||||
| import { | ||||
|   Classification, | ||||
| @ -81,3 +82,13 @@ test('logicalTypeValueLabel generates correct labels for generic type', function | ||||
|     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