Merge pull request #899 from theseyi/schemaless-tagging

schemaless dataset tagging
This commit is contained in:
Seyi Adebajo 2017-12-06 18:18:52 -08:00 committed by GitHub
commit fc6ff917a5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 459 additions and 49 deletions

View File

@ -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
*/

View 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);
}
};
}

View File

@ -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 };

View File

@ -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,

View File

@ -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,

View File

@ -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;
}

View File

@ -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';

View File

@ -2,7 +2,7 @@
* Prompt for privacy compliance
*/
.metadata-prompt {
margin: 30px 0 20px;
margin: 40px 0 10px;
/**
* Overrides default styles

View File

@ -0,0 +1 @@
@import 'schemaless-tagging';

View File

@ -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%;
}
}

View File

@ -0,0 +1 @@
@import 'toggle-switch';

View File

@ -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);
}
}
}

View File

@ -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}}

View File

@ -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>

View File

@ -268,6 +268,7 @@
<div id="compliancetab" class="tab-pane">
{{dataset-compliance
datasetName=model.name
schemaless=schemaless
platform=datasetView.platform
complianceInfo=complianceInfo
complianceSuggestion=complianceSuggestion

View File

@ -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 };

View File

@ -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);

View File

@ -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');
});

View File

@ -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'
);
});

View File

@ -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}`);
});
});