Add form submission, validation, rejection messaging

This commit is contained in:
gconsidine
2017-05-23 16:38:58 -04:00
parent c41dff7996
commit 19fa782fb4
21 changed files with 252 additions and 121 deletions

View File

@@ -4,23 +4,25 @@ function AddCredentialsController (models) {
let credential = models.credential; let credential = models.credential;
let credentialType = models.credentialType; let credentialType = models.credentialType;
vm.name = credential.getPostOptions('name'); vm.form = credential.createFormSchema('post', {
vm.description = credential.getPostOptions('description'); omit: ['user', 'team', 'inputs']
});
vm.kind = Object.assign({ vm.form.credential_type.data = credentialType.categorizeByKind();
data: credentialType.categorizeByKind(), vm.form.credential_type.placeholder = 'Select A Type';
placeholder: 'Select a Type'
}, credential.getPostOptions('credential_type'));
vm.dynamic = { vm.form.inputs = {
getInputs: credentialType.getTypeFromName, get: credentialType.getTypeFromName,
source: vm.kind, source: vm.form.credential_type,
reference: 'vm.dynamic' reference: 'vm.form.inputs',
key: 'inputs'
}; };
vm.form.save = credential.post;
} }
AddCredentialsController.$inject = [ AddCredentialsController.$inject = [
'credentialType' 'resolvedModels'
]; ];
export default AddCredentialsController; export default AddCredentialsController;

View File

@@ -7,12 +7,12 @@
</at-tab-navigation> </at-tab-navigation>
<at-panel-body> <at-panel-body>
<at-form> <at-form state="vm.form">
<at-input-text col="4" tab="1" state="vm.name"></at-input-text> <at-input-text col="4" tab="1" state="vm.form.name"></at-input-text>
<at-input-text col="4" tab="2" state="vm.description"></at-input-text> <at-input-text col="4" tab="2" state="vm.form.description"></at-input-text>
<at-input-select col="4" tab="3" state="vm.kind"></at-input-select> <at-input-select col="4" tab="3" state="vm.form.credential_type"></at-input-select>
<at-dynamic-input-group col="4" tab="4" state="vm.dynamic"> <at-dynamic-input-group col="4" tab="4" state="vm.form.inputs">
Type Details Type Details
</at-dynamic-input-group> </at-dynamic-input-group>

View File

@@ -40,7 +40,7 @@ function config ($stateExtenderProvider, pathServiceProvider) {
} }
}); });
function credentialTypeResolve ($q, credentialModel, credentialTypeModel) { function CredentialsAddResolve ($q, credentialModel, credentialTypeModel) {
let promises = [ let promises = [
credentialModel.options(), credentialModel.options(),
credentialTypeModel.get() credentialTypeModel.get()
@@ -53,7 +53,7 @@ function config ($stateExtenderProvider, pathServiceProvider) {
})); }));
} }
credentialTypeResolve.$inject = ['$q', 'CredentialModel', 'CredentialTypeModel']; CredentialsAddResolve.$inject = ['$q', 'CredentialModel', 'CredentialTypeModel'];
stateExtender.addState({ stateExtender.addState({
name: 'credentials.add', name: 'credentials.add',
@@ -69,7 +69,7 @@ function config ($stateExtenderProvider, pathServiceProvider) {
} }
}, },
resolve: { resolve: {
credentialType: credentialTypeResolve resolvedModels: CredentialsAddResolve
} }
}); });

View File

@@ -1,7 +1,6 @@
@import 'action/_index'; @import 'action/_index';
@import 'badge/_index'; @import 'badge/_index';
@import 'dynamic/_index'; @import 'dynamic/_index';
@import 'form/_index';
@import 'input/_index'; @import 'input/_index';
@import 'panel/_index'; @import 'panel/_index';
@import 'popover/_index'; @import 'popover/_index';

View File

@@ -44,7 +44,7 @@ function AtDynamicInputGroupController ($scope, $compile) {
state.value = source.value; state.value = source.value;
let inputs = state.getInputs(source.value); let inputs = state.get(source.value);
let components = vm.createComponentConfigs(inputs); let components = vm.createComponentConfigs(inputs);
vm.insert(components); vm.insert(components);
@@ -70,6 +70,7 @@ function AtDynamicInputGroupController ($scope, $compile) {
components.push(Object.assign({ components.push(Object.assign({
element: vm.createElement(input, i), element: vm.createElement(input, i),
key: 'inputs',
dynamic: true dynamic: true
}, input)); }, input));
}); });

View File

@@ -1,5 +1,5 @@
<button class="btn at-Button{{ fill }}--{{ color }}" <button class="btn at-Button{{ fill }}--{{ color }}"
ng-disabled="type !== 'cancel' && !form.isValid" ng-disabled="form.disabled || (type === 'save' && !form.isValid)"
ng-class="{ 'at-Button--disabled': !form.isValid }" ng-click="action()"> ng-click="action()">
{{::text}} {{::text}}
</button> </button>

View File

@@ -13,7 +13,9 @@ function AtFormController (eventService) {
vm.components = []; vm.components = [];
vm.state = { vm.state = {
isValid: false isValid: false,
disabled: false,
value: {}
}; };
vm.init = (_scope_, _form_) => { vm.init = (_scope_, _form_) => {
@@ -27,10 +29,6 @@ function AtFormController (eventService) {
component.category = category; component.category = category;
component.form = vm.state; component.form = vm.state;
if (category === 'input') {
component.state.index = vm.components.length;
}
vm.components.push(component) vm.components.push(component)
}; };
@@ -43,13 +41,12 @@ function AtFormController (eventService) {
}; };
vm.submitOnEnter = event => { vm.submitOnEnter = event => {
if (event.key !== 'Enter') { if (event.key !== 'Enter' || event.srcElement.type === 'textarea') {
return; return;
} }
event.preventDefault(); event.preventDefault();
scope.$apply(vm.submit);
vm.submit();
}; };
vm.submit = event => { vm.submit = event => {
@@ -57,7 +54,58 @@ function AtFormController (eventService) {
return; return;
} }
console.log('submit', event, vm.components); vm.state.disabled = true;
let data = vm.components
.filter(component => component.category === 'input')
.reduce((values, component) => {
if (!component.state.value) {
return values;
}
if (component.state.dynamic) {
values[component.state.key] = values[component.state.key] || [];
values[component.state.key].push({
[component.state.id]: component.state.value
});
} else {
values[component.state.id] = component.state.value;
}
return values;
}, {});
scope.state.save(data)
.then(res => vm.onSaveSuccess(res))
.catch(err => vm.onSaveError(err))
.finally(() => vm.state.disabled = false);
};
vm.onSaveSuccess = res => {
console.info(res);
};
vm.onSaveError = err => {
if (err.status === 400) {
vm.setValidationErrors(err.data);
}
};
vm.setValidationErrors = errors => {
for (let id in errors) {
vm.components
.filter(component => component.category === 'input')
.forEach(component => {
if (component.state.id === id) {
component.state.rejected = true;
component.state.isValid = false;
component.state.message = errors[id].join(' ');
}
});
}
vm.check();
}; };
vm.validate = () => { vm.validate = () => {
@@ -86,12 +134,14 @@ function AtFormController (eventService) {
}; };
vm.deregisterDynamicComponents = components => { vm.deregisterDynamicComponents = components => {
let offset = 0; for (let i = 0; i < components.length; i++) {
for (let j = 0; j < vm.components.length; j++) {
components.forEach(component => { if (components[i] === vm.components[j].state) {
vm.components.splice(component.index - offset, 1); vm.components.splice(j, 1);
offset++; break;
}); }
}
}
}; };
} }

View File

@@ -18,7 +18,6 @@ import toggleContent from './toggle/content.directive';
import BaseInputController from './input/base.controller'; import BaseInputController from './input/base.controller';
angular angular
.module('at.lib.components', []) .module('at.lib.components', [])
.directive('atActionGroup', actionGroup) .directive('atActionGroup', actionGroup)

View File

@@ -19,7 +19,10 @@
border-color: @at-blue; border-color: @at-blue;
} }
.at-InputLabel { .at-Input--rejected {
&, &:focus {
border-color: @at-red;
}
} }
.at-InputLabel-name { .at-InputLabel-name {
@@ -77,3 +80,10 @@
background-color: @at-white; background-color: @at-white;
} }
} }
.at-InputMessage--rejected {
font-size: @at-font-size;
color: @at-red;
margin: @at-space-3x 0 0 0;
padding: 0;
}

View File

@@ -1,3 +1,6 @@
const REQUIRED_INPUT_MISSING_MESSAGE = 'Please enter a value.';
const DEFAULT_INVALID_INPUT_MESSAGE = 'Invalid input for this type.';
function BaseInputController () { function BaseInputController () {
return function extend (type, scope, element, form) { return function extend (type, scope, element, form) {
let vm = this; let vm = this;
@@ -12,23 +15,36 @@ function BaseInputController () {
vm.validate = () => { vm.validate = () => {
let isValid = true; let isValid = true;
let message = '';
if (scope.state.required && !scope.state.value) { if (scope.state.required && !scope.state.value) {
isValid = false; isValid = false;
message = REQUIRED_INPUT_MISSING_MESSAGE;
} }
if (scope.state.validate && !scope.state.validate(scope.state.value)) { if (scope.state.validate) {
isValid = false; let result = scope.state.validate(scope.state.value);
if (!result.isValid) {
isValid = false;
message = result.message || DEFAULT_INVALID_INPUT_MESSAGE;
}
} }
return isValid; return {
isValid,
message
};
}; };
vm.check = () => { vm.check = () => {
let isValid = vm.validate(); let result = vm.validate();
if (result.isValid !== scope.state.isValid) {
scope.state.rejected = !result.isValid;
scope.state.isValid = result.isValid;
scope.state.message = result.message;
if (isValid !== scope.state.isValid) {
scope.state.isValid = isValid;
form.check(); form.check();
} }
}; };

View File

@@ -3,15 +3,21 @@
<at-input-label state="state"></at-input-label> <at-input-label state="state"></at-input-label>
<div class="input-group"> <div class="input-group">
<span class="input-group-btn"> <span class="input-group-btn">
<button class="btn at-ButtonHollow--white at-Input-button" ng-click="vm.toggle()"> <button class="btn at-ButtonHollow--white at-Input-button"
ng-disabled="state.disabled || form.disabled" ng-click="vm.toggle()">
{{ buttonText }} {{ buttonText }}
</button> </button>
</span> </span>
<input type="{{ type }}" class="form-control at-Input" ng-model="state.value" <input type="{{ type }}" class="form-control at-Input" ng-model="state.value"
ng-class="{ 'at-Input--rejected': state.rejected }"
ng-attr-maxlength="{{ state.options.max_length || undefined }}" ng-attr-maxlength="{{ state.options.max_length || undefined }}"
ng-attr-tabindex="{{ tab || undefined }}" ng-attr-tabindex="{{ tab || undefined }}"
ng-attr-placeholder="{{::state.placeholder || undefined }}" ng-attr-placeholder="{{::state.placeholder || undefined }}"
ng-change="vm.check()" ng-disabled="state.disabled" /> ng-change="vm.check()"
ng-disabled="state.disabled || form.disabled" />
</div> </div>
<p ng-if="state.rejected && !state.isValid" class="at-InputMessage--rejected">
{{ state.message }}
</p>
</div> </div>
</div> </div>

View File

@@ -4,13 +4,16 @@
<div class="at-InputGroup at-InputSelect"> <div class="at-InputGroup at-InputSelect">
<input type="text" class="form-control at-Input at-InputSelect-input" <input type="text" class="form-control at-Input at-InputSelect-input"
placeholder="{{ state.placeholder | uppercase }}" placeholder="{{ state.placeholder | uppercase }}"
ng-model="state.value" ng-disabled="state.disabled" ng-class="{ 'at-Input--rejected': state.rejected }"
ng-model="state.value"
ng-disabled="state.disabled || form.disabled"
ng-change="vm.check()" /> ng-change="vm.check()" />
<select class="form-control at-InputSelect-select" ng-model="state.value" <select class="form-control at-InputSelect-select" ng-model="state.value"
ng-attr-tabindex="{{ tab || undefined }}" ng-attr-tabindex="{{ tab || undefined }}"
ng-disabled="state.disabled"> ng-disabled="state.disabled || form.disabled">
<optgroup ng-repeat="group in state.data" label="{{::group.category | uppercase }}"> <optgroup ng-repeat="group in state.data"
label="{{::group.category | uppercase }}">
<option ng-repeat="item in group.data" value="{{ item.name }}"> <option ng-repeat="item in group.data" value="{{ item.name }}">
{{ item.name }} {{ item.name }}
</option> </option>
@@ -18,5 +21,8 @@
</select> </select>
<i class="fa" ng-class="{ 'fa-chevron-down': !open, 'fa-chevron-up': open }"></i> <i class="fa" ng-class="{ 'fa-chevron-down': !open, 'fa-chevron-up': open }"></i>
</div> </div>
<p ng-if="state.rejected && !state.isValid" class="at-InputMessage--rejected">
{{ state.message }}
</p>
</div> </div>
</div> </div>

View File

@@ -1,10 +1,17 @@
<div class="col-sm-{{::col}}"> <div class="col-sm-{{::col}}">
<div class="form-group at-u-flat"> <div class="form-group at-u-flat">
<at-input-label state="state"></at-input-label> <at-input-label state="state"></at-input-label>
<input type="text" class="form-control at-Input" ng-model="state.value" <input type="text" class="form-control at-Input"
ng-class="{ 'at-Input--rejected': state.rejected }"
ng-model="state.value"
ng-attr-maxlength="{{ state.options.max_length || undefined }}" ng-attr-maxlength="{{ state.options.max_length || undefined }}"
ng-attr-tabindex="{{ tab || undefined }}" ng-attr-tabindex="{{ tab || undefined }}"
ng-attr-placeholder="{{::state.placeholder || undefined }}" ng-attr-placeholder="{{::state.placeholder || undefined }}"
ng-change="vm.check()" ng-disabled="state.disabled" /> ng-change="vm.check()"
ng-disabled="state.disabled || form.disabled" />
<p ng-if="state.rejected && !state.isValid" class="at-InputMessage--rejected">
{{ state.message }}
</p>
</div> </div>
</div> </div>

View File

@@ -2,10 +2,15 @@
<div class="form-group at-u-flat"> <div class="form-group at-u-flat">
<at-input-label state="state"></at-input-label> <at-input-label state="state"></at-input-label>
<textarea class="form-control at-Input" ng-model="state.value" <textarea class="form-control at-Input" ng-model="state.value"
ng-attr-maxlength="{{ state.options.max_length || undefined }}" ng-class="{ 'at-Input--rejected': state.rejected }"
ng-attr-tabindex="{{ tab || undefined }}" ng-attr-maxlength="{{ state.options.max_length || undefined }}"
ng-attr-placeholder="{{::state.placeholder || undefined }}" ng-attr-tabindex="{{ tab || undefined }}"
ng-change="vm.check()" ng-disabled="state.disabled" /> ng-attr-placeholder="{{::state.placeholder || undefined }}"
ng-change="vm.check()"
ng-disabled="state.disabled || form.disabled" />
</textarea> </textarea>
<p ng-if="state.rejected && !state.isValid" class="at-InputMessage--rejected">
{{ state.message }}
</p>
</div> </div>
</div> </div>

View File

@@ -1,16 +1,13 @@
function use (scope) {
scope.dismiss = this.dismiss;
}
function dismiss ($state) {
$state.go('^');
}
function AtPanelController ($state) { function AtPanelController ($state) {
let vm = this; let vm = this;
vm.dismiss = dismiss.bind(vm, $state); vm.dismiss = () => {
vm.use = use; $state.go('^');
};
vm.use = scope => {
scope.dismiss = this.dismiss;
};
} }
AtPanelController.$inject = ['$state']; AtPanelController.$inject = ['$state'];

View File

@@ -1,49 +1,71 @@
let $resource; function BaseModel ($http) {
function get() {
return $resource(this.path).get().$promise
.then(response => {
this.model.data = response;
});
}
function options () {
let actions = {
options: {
method: 'OPTIONS'
}
};
return $resource(this.path, null, actions).options().$promise
.then(response => {
this.model.options = response;
});
}
function getPostOptions (name) {
return this.model.options.actions.POST[name];
}
function normalizePath (resource) {
let version = '/api/v2/';
return `${version}${resource}/`;
}
function BaseModel (_$resource_) {
$resource = _$resource_;
return function extend (path) { return function extend (path) {
this.get = get; this.get = () => {
this.options = options; let request = {
this.getPostOptions = getPostOptions; method: 'GET',
this.normalizePath = normalizePath; url: this.path
};
return $http(request)
.then(response => {
this.model.get = response;
});
};
this.post = data => {
let request = {
method: 'POST',
url: this.path,
data,
};
return $http(request)
.then(response => {
this.model.post = response;
});
};
this.options = () => {
let request = {
method: 'OPTIONS',
url: this.path
};
return $http(request)
.then(response => {
this.model.options = response;
});
};
this.getOptions = (method, key) => {
if (!method) {
return this.model.options.data;
}
method = method.toUpperCase();
if (method && !key) {
return this.model.options.data.actions[method];
}
if (method && key) {
return this.model.options.data.actions[method][key];
}
return null;
};
this.normalizePath = resource => {
let version = '/api/v2/';
return `${version}${resource}/`;
};
this.model = {}; this.model = {};
this.path = this.normalizePath(path); this.path = this.normalizePath(path);
}; };
} }
BaseModel.$inject = ['$resource']; BaseModel.$inject = ['$http'];
export default BaseModel; export default BaseModel;

View File

@@ -1,7 +1,23 @@
function CredentialModel (BaseModel) { function CredentialModel (BaseModel, CredentialTypeModel) {
BaseModel.call(this, 'credentials'); BaseModel.call(this, 'credentials');
this.createFormSchema = (type, config) => {
let schema = Object.assign({}, this.getOptions(type));
if (config && config.omit) {
config.omit.forEach(key => {
delete schema[key];
});
}
for (let key in schema) {
schema[key].id = key;
}
return schema;
};
} }
CredentialModel.$inject = ['BaseModel']; CredentialModel.$inject = ['BaseModel', 'CredentialTypeModel'];
export default CredentialModel; export default CredentialModel;

View File

@@ -4,7 +4,7 @@ function CredentialTypeModel (BaseModel) {
this.categorizeByKind = () => { this.categorizeByKind = () => {
let group = {}; let group = {};
this.model.data.results.forEach(result => { this.model.get.data.results.forEach(result => {
group[result.kind] = group[result.kind] || []; group[result.kind] = group[result.kind] || [];
group[result.kind].push(result); group[result.kind].push(result);
}); });
@@ -16,7 +16,7 @@ function CredentialTypeModel (BaseModel) {
}; };
this.getTypeFromName = name => { this.getTypeFromName = name => {
let type = this.model.data.results.filter(result => result.name === name); let type = this.model.get.data.results.filter(result => result.name === name);
if (!type.length) { if (!type.length) {
return null; return null;
@@ -36,6 +36,10 @@ function CredentialTypeModel (BaseModel) {
return field; return field;
}); });
}; };
this.getResults = () => {
return this.model.get.data.results;
};
} }
CredentialTypeModel.$inject = ['BaseModel']; CredentialTypeModel.$inject = ['BaseModel'];

View File

@@ -2,15 +2,8 @@ import Base from './Base';
import Credential from './Credential'; import Credential from './Credential';
import CredentialType from './CredentialType'; import CredentialType from './CredentialType';
function config ($resourceProvider) {
$resourceProvider.defaults.stripTrailingSlashes = false;
}
config.$inject = ['$resourceProvider'];
angular angular
.module('at.lib.models', []) .module('at.lib.models', [])
.config(config)
.service('BaseModel', Base) .service('BaseModel', Base)
.service('CredentialModel', Credential) .service('CredentialModel', Credential)
.service('CredentialTypeModel', CredentialType); .service('CredentialTypeModel', CredentialType);

View File

@@ -136,7 +136,6 @@ var tower = angular.module('Tower', [
'AWDirectives', 'AWDirectives',
'features', 'features',
'ngResource',
'at.lib.components', 'at.lib.components',
'at.lib.models', 'at.lib.models',
'at.lib.services', 'at.lib.services',