diff --git a/.editorconfig b/.editorconfig
index efe5869a73..ed45b0d432 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -11,7 +11,7 @@ indent_style = tab
indent_style = space
indent_size = 4
-[**.{js,less}]
+[**.{js,less,html}]
indent_style = space
indent_size = 4
diff --git a/awx/ui/.eslintignore b/awx/ui/.eslintignore
index 9222615c2c..44869da7c0 100644
--- a/awx/ui/.eslintignore
+++ b/awx/ui/.eslintignore
@@ -3,6 +3,7 @@ Gruntfile.js
karma.*.js
etc
+coverage
grunt-tasks
node_modules
po
@@ -10,4 +11,7 @@ static
templates
tests
client/**/*.js
+test
+!client/component/**/*.js
+!client/model/**/*.js
diff --git a/awx/ui/.eslintrc.js b/awx/ui/.eslintrc.js
index 9eabfdcf47..6d1174fa55 100644
--- a/awx/ui/.eslintrc.js
+++ b/awx/ui/.eslintrc.js
@@ -15,8 +15,9 @@ module.exports = {
jsyaml: true
},
rules: {
- indent: ['error', 4],
- 'comma-dangle': 'off',
- 'prefer-const': ['off']
+ indent: [0, 4],
+ 'comma-dangle': 0,
+ 'prefer-const': 0,
+ 'space-before-function-paren': [2, 'always']
}
};
diff --git a/awx/ui/client/features/_index.less b/awx/ui/client/features/_index.less
new file mode 100644
index 0000000000..5046881522
--- /dev/null
+++ b/awx/ui/client/features/_index.less
@@ -0,0 +1 @@
+@import 'credentials/_index';
diff --git a/awx/ui/client/features/credentials/_index.less b/awx/ui/client/features/credentials/_index.less
new file mode 100644
index 0000000000..4f4f37cd91
--- /dev/null
+++ b/awx/ui/client/features/credentials/_index.less
@@ -0,0 +1,3 @@
+.at-CredentialsPermissions {
+ margin-top: 20px;
+}
diff --git a/awx/ui/client/features/credentials/add-credentials.controller.js b/awx/ui/client/features/credentials/add-credentials.controller.js
new file mode 100644
index 0000000000..b9a0a9fce6
--- /dev/null
+++ b/awx/ui/client/features/credentials/add-credentials.controller.js
@@ -0,0 +1,63 @@
+const DEFAULT_ORGANIZATION_PLACEHOLDER = 'SELECT AN ORGANIZATION';
+
+function AddCredentialsController (models, $state) {
+ let vm = this || {};
+
+ let me = models.me;
+ let credential = models.credential;
+ let credentialType = models.credentialType;
+ let organization = models.organization;
+
+ vm.panelTitle = 'NEW CREDENTIAL';
+
+ vm.tab = {
+ details: {
+ _active: true
+ },
+ permissions:{
+ _disabled: true
+ }
+ };
+
+ vm.form = credential.createFormSchema('post', {
+ omit: ['user', 'team', 'inputs']
+ });
+
+ vm.form.organization._placeholder = DEFAULT_ORGANIZATION_PLACEHOLDER;
+ vm.form.organization._data = organization.get('results');
+ vm.form.organization._format = 'objects';
+ vm.form.organization._exp = 'org as org.name for org in state._data';
+ vm.form.organization._display = 'name';
+ vm.form.organization._key = 'id';
+
+ vm.form.credential_type._data = credentialType.get('results');
+ vm.form.credential_type._placeholder = 'SELECT A TYPE';
+ vm.form.credential_type._format = 'grouped-object';
+ vm.form.credential_type._display = 'name';
+ vm.form.credential_type._key = 'id';
+ vm.form.credential_type._exp = 'type as type.name group by type.kind for type in state._data';
+
+ vm.form.inputs = {
+ _get: credentialType.mergeInputProperties,
+ _source: vm.form.credential_type,
+ _reference: 'vm.form.inputs',
+ _key: 'inputs'
+ };
+
+ vm.form.save = data => {
+ data.user = me.getSelf().id;
+
+ return credential.request('post', data);
+ };
+
+ vm.form.onSaveSuccess = res => {
+ $state.go('credentials.edit', { credential_id: res.data.id }, { reload: true });
+ };
+}
+
+AddCredentialsController.$inject = [
+ 'resolvedModels',
+ '$state'
+];
+
+export default AddCredentialsController;
diff --git a/awx/ui/client/features/credentials/add-edit-credentials.view.html b/awx/ui/client/features/credentials/add-edit-credentials.view.html
new file mode 100644
index 0000000000..d8b45d7a30
--- /dev/null
+++ b/awx/ui/client/features/credentials/add-edit-credentials.view.html
@@ -0,0 +1,46 @@
+
+ {{ vm.panelTitle }}
+
+
+ Details
+ Permissions
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Type Details
+
+
+
+
+
+
+
+
+
+
+
+ Credentials Permissions
+
+
+ Details
+ Permissions
+
+
+
+
+
+
+
+
+
diff --git a/awx/ui/client/features/credentials/edit-credentials.controller.js b/awx/ui/client/features/credentials/edit-credentials.controller.js
new file mode 100644
index 0000000000..b7370fe7c6
--- /dev/null
+++ b/awx/ui/client/features/credentials/edit-credentials.controller.js
@@ -0,0 +1,90 @@
+const DEFAULT_ORGANIZATION_PLACEHOLDER = 'SELECT AN ORGANIZATION';
+
+function EditCredentialsController (models, $state, $scope) {
+ let vm = this || {};
+
+ let me = models.me;
+ let credential = models.credential;
+ let credentialType = models.credentialType;
+ let organization = models.organization;
+
+ vm.tab = {
+ details: {
+ _active: true,
+ _go: 'credentials.edit',
+ _params: { credential_id: credential.get('id') }
+ },
+ permissions:{
+ _go: 'credentials.edit.permissions',
+ _params: { credential_id: credential.get('id') }
+ }
+ };
+
+ $scope.$watch('$state.current.name', (value) => {
+ if (value === 'credentials.edit') {
+ vm.tab.details._active = true;
+ vm.tab.details._permissions = false;
+ } else {
+ vm.tab.permissions._active = true;
+ vm.tab.details._active = false;
+ }
+ });
+
+ // Only exists for permissions compatibility
+ $scope.credential_obj = credential.get();
+
+ vm.panelTitle = credential.get('name');
+
+ vm.form = credential.createFormSchema('put', {
+ omit: ['user', 'team', 'inputs']
+ });
+
+ vm.form.organization._placeholder = DEFAULT_ORGANIZATION_PLACEHOLDER;
+ vm.form.organization._data = organization.get('results');
+ vm.form.organization._format = 'objects';
+ vm.form.organization._exp = 'org as org.name for org in state._data';
+ vm.form.organization._display = 'name';
+ vm.form.organization._key = 'id';
+ vm.form.organization._value = organization.getById(credential.get('organization'));
+
+ vm.form.credential_type._data = credentialType.get('results');
+ vm.form.credential_type._format = 'grouped-object';
+ vm.form.credential_type._display = 'name';
+ vm.form.credential_type._key = 'id';
+ vm.form.credential_type._exp = 'type as type.name group by type.kind for type in state._data';
+ vm.form.credential_type._value = credentialType.getById(credential.get('credential_type'));
+
+ vm.form.inputs = {
+ _get (type) {
+ let inputs = credentialType.mergeInputProperties(type);
+
+ if (type.id === credential.get('credential_type')) {
+ inputs = credential.assignInputGroupValues(inputs);
+ }
+
+ return inputs;
+ },
+ _source: vm.form.credential_type,
+ _reference: 'vm.form.inputs',
+ _key: 'inputs'
+ };
+
+ vm.form.save = data => {
+ data.user = me.getSelf().id;
+ credential.clearTypeInputs();
+
+ return credential.request('put', data);
+ };
+
+ vm.form.onSaveSuccess = res => {
+ $state.go('credentials', { reload: true });
+ };
+}
+
+EditCredentialsController.$inject = [
+ 'resolvedModels',
+ '$state',
+ '$scope'
+];
+
+export default EditCredentialsController;
diff --git a/awx/ui/client/features/credentials/index.js b/awx/ui/client/features/credentials/index.js
new file mode 100644
index 0000000000..e8c5670cbe
--- /dev/null
+++ b/awx/ui/client/features/credentials/index.js
@@ -0,0 +1,283 @@
+import PermissionsList from '../../src/access/permissions-list.controller';
+import CredentialForm from '../../src/credentials/credentials.form';
+import CredentialList from '../../src/credentials/credentials.list';
+import ListController from '../../src/credentials/list/credentials-list.controller';
+import AddController from './add-credentials.controller.js';
+import EditController from './edit-credentials.controller.js';
+import { N_ } from '../../src/i18n';
+
+function CredentialsResolve ($q, $stateParams, Me, Credential, CredentialType, Organization) {
+ let id = $stateParams.credential_id;
+
+ let promises = {
+ me: new Me('get'),
+ credentialType: new CredentialType('get'),
+ organization: new Organization('get')
+ };
+
+ if (id) {
+ promises.credential = new Credential(['get', 'options'], [id, id]);
+ } else {
+ promises.credential = new Credential('options');
+ }
+
+ return $q.all(promises);
+}
+
+CredentialsResolve.$inject = [
+ '$q',
+ '$stateParams',
+ 'MeModel',
+ 'CredentialModel',
+ 'CredentialTypeModel',
+ 'OrganizationModel'
+];
+
+function CredentialsConfig ($stateProvider, $stateExtenderProvider, pathServiceProvider) {
+ let pathService = pathServiceProvider.$get();
+ let stateExtender = $stateExtenderProvider.$get();
+
+ stateExtender.addState({
+ name: 'credentials',
+ route: '/credentials',
+ ncyBreadcrumb: {
+ label: N_('CREDENTIALS')
+ },
+ views: {
+ '@': {
+ templateUrl: pathService.getViewPath('credentials/index')
+ },
+ 'list@credentials': {
+ templateProvider: function(CredentialList, generateList) {
+ let html = generateList.build({
+ list: CredentialList,
+ mode: 'edit'
+ });
+
+ return html;
+ },
+ controller: ListController
+ }
+ },
+ searchPrefix: 'credential',
+ resolve: {
+ Dataset: ['CredentialList', 'QuerySet', '$stateParams', 'GetBasePath',
+ function(list, qs, $stateParams, GetBasePath) {
+ let path = GetBasePath(list.basePath) || GetBasePath(list.name);
+ return qs.search(path, $stateParams[`${list.iterator}_search`]);
+ }
+ ]
+ }
+ });
+
+ stateExtender.addState({
+ name: 'credentials.add',
+ route: '/add',
+ ncyBreadcrumb: {
+ label: N_('CREATE CREDENTIALS')
+ },
+ views: {
+ 'add@credentials': {
+ templateUrl: pathService.getViewPath('credentials/add-edit-credentials'),
+ controller: AddController,
+ controllerAs: 'vm'
+ }
+ },
+ resolve: {
+ resolvedModels: CredentialsResolve
+ }
+ });
+
+ stateExtender.addState({
+ name: 'credentials.edit',
+ route: '/:credential_id',
+ ncyBreadcrumb: {
+ label: N_('EDIT')
+ },
+ views: {
+ 'edit@credentials': {
+ templateUrl: pathService.getViewPath('credentials/add-edit-credentials'),
+ controller: EditController,
+ controllerAs: 'vm'
+ }
+ },
+ resolve: {
+ resolvedModels: CredentialsResolve
+ }
+ });
+
+ stateExtender.addState({
+ name: "credentials.edit.permissions",
+ url: "/permissions?{permission_search:queryset}",
+ resolve: {
+ ListDefinition: () => {
+ return {
+ name: 'permissions',
+ disabled: '(organization === undefined ? true : false)',
+ // Do not transition the state if organization is undefined
+ ngClick: `(organization === undefined ? true : false)||$state.go('credentials.edit.permissions')`,
+ awToolTip: '{{permissionsTooltip}}',
+ dataTipWatch: 'permissionsTooltip',
+ awToolTipTabEnabledInEditMode: true,
+ dataPlacement: 'right',
+ basePath: 'api/v2/credentials/{{$stateParams.id}}/access_list/',
+ search: {
+ order_by: 'username'
+ },
+ type: 'collection',
+ title: N_('Permissions'),
+ iterator: 'permission',
+ index: false,
+ open: false,
+ actions: {
+ add: {
+ ngClick: "$state.go('.add')",
+ label: 'Add',
+ awToolTip: N_('Add a permission'),
+ actionClass: 'btn List-buttonSubmit',
+ buttonContent: '+ ' + N_('ADD'),
+ ngShow: '(credential_obj.summary_fields.user_capabilities.edit || canAdd)'
+ }
+ },
+ fields: {
+ username: {
+ key: true,
+ label: N_('User'),
+ linkBase: 'users',
+ class: 'col-lg-3 col-md-3 col-sm-3 col-xs-4'
+ },
+ role: {
+ label: N_('Role'),
+ type: 'role',
+ nosort: true,
+ class: 'col-lg-4 col-md-4 col-sm-4 col-xs-4'
+ },
+ team_roles: {
+ label: N_('Team Roles'),
+ type: 'team_roles',
+ nosort: true,
+ class: 'col-lg-5 col-md-5 col-sm-5 col-xs-4'
+ }
+ }
+ };
+ },
+ Dataset: ['QuerySet', '$stateParams', (qs, $stateParams) => {
+ let id = $stateParams.credential_id;
+ let path = `api/v2/credentials/${id}/access_list/`;
+
+ return qs.search(path, $stateParams[`permission_search`]);
+ }
+ ]
+ },
+ params: {
+ permission_search: {
+ value: {
+ page_size: "20",
+ order_by: "username"
+ },
+ dynamic:true,
+ squash:""
+ }
+ },
+ ncyBreadcrumb: {
+ parent: "credentials.edit",
+ label: "PERMISSIONS"
+ },
+ views: {
+ 'related': {
+ templateProvider: function(CredentialForm, GenerateForm) {
+ let html = GenerateForm.buildCollection({
+ mode: 'edit',
+ related: `permissions`,
+ form: typeof(CredentialForm) === 'function' ?
+ CredentialForm() : CredentialForm
+ });
+ return html;
+ },
+ controller: 'PermissionsList'
+ }
+ }
+ });
+
+ stateExtender.addState({
+ name: 'credentials.edit.permissions.add',
+ url: '/add-permissions',
+ resolve: {
+ usersDataset: [
+ 'addPermissionsUsersList',
+ 'QuerySet',
+ '$stateParams',
+ 'GetBasePath',
+ (list, qs, $stateParams, GetBasePath) => {
+ let path = GetBasePath(list.basePath) || GetBasePath(list.name);
+ return qs.search(path, $stateParams.user_search);
+
+ }
+ ],
+ teamsDataset: [
+ 'addPermissionsTeamsList',
+ 'QuerySet',
+ '$stateParams',
+ 'GetBasePath',
+ (list, qs, $stateParams, GetBasePath) => {
+ let path = GetBasePath(list.basePath) || GetBasePath(list.name);
+ return qs.search(path, $stateParams.team_search);
+ }
+ ],
+ resourceData: ['CredentialModel', '$stateParams', (Credential, $stateParams) => {
+ return new Credential('get', $stateParams.credential_id)
+ .then(credential => ({ data: credential.get() }));
+ }],
+ },
+ params: {
+ user_search: {
+ value: {
+ order_by: 'username',
+ page_size: 5
+ },
+ dynamic: true
+ },
+ team_search: {
+ value: {
+ order_by: 'name',
+ page_size: 5
+ },
+ dynamic: true
+ }
+ },
+ ncyBreadcrumb: {
+ skip: true
+ },
+ views: {
+ 'modal@credentials.edit': {
+ template: `
+
+ `
+ }
+ },
+ onExit: $state => {
+ if ($state.transition) {
+ $('#add-permissions-modal').modal('hide');
+ $('.modal-backdrop').remove();
+ $('body').removeClass('modal-open');
+ }
+ }
+ });
+}
+
+CredentialsConfig.$inject = [
+ '$stateProvider',
+ '$stateExtenderProvider',
+ 'PathServiceProvider'
+];
+
+angular
+ .module('at.features.credentials', [])
+ .config(CredentialsConfig)
+ .controller('AddController', AddController)
+ .controller('EditController', EditController);
diff --git a/awx/ui/client/features/credentials/index.view.html b/awx/ui/client/features/credentials/index.view.html
new file mode 100644
index 0000000000..31993f35b5
--- /dev/null
+++ b/awx/ui/client/features/credentials/index.view.html
@@ -0,0 +1,6 @@
+
+
+
+
diff --git a/awx/ui/client/features/index.js b/awx/ui/client/features/index.js
new file mode 100644
index 0000000000..30ce87123d
--- /dev/null
+++ b/awx/ui/client/features/index.js
@@ -0,0 +1,5 @@
+import './credentials';
+
+angular.module('at.features', [
+ 'at.features.credentials'
+]);
diff --git a/awx/ui/client/lib/components/_index.less b/awx/ui/client/lib/components/_index.less
new file mode 100644
index 0000000000..d26fbbc11d
--- /dev/null
+++ b/awx/ui/client/lib/components/_index.less
@@ -0,0 +1,7 @@
+@import 'action/_index';
+@import 'input/_index';
+@import 'panel/_index';
+@import 'modal/_index';
+@import 'popover/_index';
+@import 'tabs/_index';
+@import 'utility/_index';
diff --git a/awx/ui/client/lib/components/action/_index.less b/awx/ui/client/lib/components/action/_index.less
new file mode 100644
index 0000000000..95231f8a41
--- /dev/null
+++ b/awx/ui/client/lib/components/action/_index.less
@@ -0,0 +1,7 @@
+.at-ActionGroup {
+ margin-top: @at-space-6x;
+
+ button:last-child {
+ margin-left: @at-space-5x;
+ }
+}
diff --git a/awx/ui/client/lib/components/action/action-group.directive.js b/awx/ui/client/lib/components/action/action-group.directive.js
new file mode 100644
index 0000000000..41655c379a
--- /dev/null
+++ b/awx/ui/client/lib/components/action/action-group.directive.js
@@ -0,0 +1,16 @@
+function atActionGroup (pathService) {
+ return {
+ restrict: 'E',
+ replace: true,
+ transclude: true,
+ templateUrl: pathService.getPartialPath('components/action/action-group'),
+ scope: {
+ col: '@',
+ pos: '@'
+ }
+ };
+}
+
+atActionGroup.$inject = ['PathService'];
+
+export default atActionGroup;
diff --git a/awx/ui/client/lib/components/action/action-group.partial.html b/awx/ui/client/lib/components/action/action-group.partial.html
new file mode 100644
index 0000000000..e0df9581ac
--- /dev/null
+++ b/awx/ui/client/lib/components/action/action-group.partial.html
@@ -0,0 +1,5 @@
+
diff --git a/awx/ui/client/lib/components/form/action.directive.js b/awx/ui/client/lib/components/form/action.directive.js
new file mode 100644
index 0000000000..883e94cb89
--- /dev/null
+++ b/awx/ui/client/lib/components/form/action.directive.js
@@ -0,0 +1,74 @@
+function link (scope, element, attrs, controllers) {
+ let formController = controllers[0];
+ let actionController = controllers[1];
+
+ actionController.init(formController, element, scope);
+}
+
+function atFormActionController ($state) {
+ let vm = this || {};
+
+ let element;
+ let form;
+ let scope;
+
+ vm.init = (_form_, _element_, _scope_) => {
+ form = _form_;
+ element = _element_;
+ scope = _scope_;
+
+ switch(scope.type) {
+ case 'cancel':
+ vm.setCancelDefaults();
+ break;
+ case 'save':
+ vm.setSaveDefaults();
+ break;
+ default:
+ vm.setCustomDefaults();
+ }
+
+ form.register('action', scope);
+ };
+
+ vm.setCustomDefaults = () => {
+
+ };
+
+ vm.setCancelDefaults = () => {
+ scope.text = 'CANCEL';
+ scope.fill = 'Hollow';
+ scope.color = 'white';
+ scope.action = () => $state.go('^');
+ };
+
+ vm.setSaveDefaults = () => {
+ scope.text = 'SAVE';
+ scope.fill = '';
+ scope.color = 'green';
+ scope.action = () => form.submit();
+ };
+}
+
+atFormAction.$inject = ['$state'];
+
+function atFormAction (pathService) {
+ return {
+ restrict: 'E',
+ transclude: true,
+ replace: true,
+ require: ['^^atForm', 'atFormAction'],
+ templateUrl: pathService.getPartialPath('components/form/action'),
+ controller: atFormActionController,
+ controllerAs: 'vm',
+ link,
+ scope: {
+ state: '=',
+ type: '@'
+ }
+ };
+}
+
+atFormAction.$inject = ['PathService'];
+
+export default atFormAction;
diff --git a/awx/ui/client/lib/components/form/action.partial.html b/awx/ui/client/lib/components/form/action.partial.html
new file mode 100644
index 0000000000..8affd3a414
--- /dev/null
+++ b/awx/ui/client/lib/components/form/action.partial.html
@@ -0,0 +1,5 @@
+
diff --git a/awx/ui/client/lib/components/form/form.directive.js b/awx/ui/client/lib/components/form/form.directive.js
new file mode 100644
index 0000000000..7838ace48b
--- /dev/null
+++ b/awx/ui/client/lib/components/form/form.directive.js
@@ -0,0 +1,209 @@
+function atFormLink (scope, el, attrs, controllers) {
+ let formController = controllers[0];
+ let form = el[0];
+
+ formController.init(scope, form);
+}
+
+function AtFormController (eventService) {
+ let vm = this || {};
+
+ let scope;
+ let form;
+
+ vm.components = [];
+ vm.modal = {};
+ vm.state = {
+ isValid: false,
+ disabled: false,
+ value: {},
+ };
+
+ vm.init = (_scope_, _form_) => {
+ scope = _scope_;
+ form = _form_;
+
+ vm.setListeners();
+ };
+
+ vm.register = (category, component, el) => {
+ component.category = category;
+ component.form = vm.state;
+
+ vm.components.push(component)
+ };
+
+ vm.setListeners = () => {
+ let listeners = eventService.addListeners([
+ [form, 'keypress', vm.submitOnEnter]
+ ]);
+
+ scope.$on('$destroy', () => eventService.remove(listeners));
+ };
+
+ vm.submitOnEnter = event => {
+ if (event.key !== 'Enter' || event.srcElement.type === 'textarea') {
+ return;
+ }
+
+ event.preventDefault();
+ scope.$apply(vm.submit);
+ };
+
+ vm.submit = event => {
+ if (!vm.state.isValid) {
+ return;
+ }
+
+ 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._key && typeof component.state._value === 'object') {
+ values[component.state.id] = component.state._value[component.state._key];
+ } else if (component.state._group) {
+ values[component.state._key] = values[component.state._key] || {};
+ values[component.state._key][component.state.id] = component.state._value;
+ } else {
+ values[component.state.id] = component.state._value;
+ }
+
+ return values;
+ }, {});
+
+ scope.state.save(data)
+ .then(scope.state.onSaveSuccess)
+ .catch(err => vm.onSaveError(err))
+ .finally(() => vm.state.disabled = false);
+ };
+
+ vm.onSaveError = err => {
+ let handled;
+
+ if (err.status === 400) {
+ handled = vm.handleValidationError(err.data);
+ }
+
+ if (err.status === 500) {
+ handled = vm.handleUnexpectedError(err);
+ }
+
+ if (!handled) {
+ let message;
+
+ if (typeof err.data === 'object') {
+ message = JSON.stringify(err.data);
+ } else {
+ message = err.data;
+ }
+
+ vm.modal.show('Unable to Submit', `Unexpected Error: ${message}`);
+ }
+ };
+
+ vm.handleUnexpectedError = err => {
+ let title = 'Unable to Submit';
+ let message = 'Unexpected server error. View the console for more information';
+
+ vm.modal.show(title, message);
+
+ return true;
+ };
+
+ vm.handleValidationError = errors => {
+ let errorMessageSet = vm.setValidationMessages(errors);
+
+ if (errorMessageSet) {
+ vm.check();
+ }
+
+ return errorMessageSet;
+ };
+
+ vm.setValidationMessages = (errors, errorSet) => {
+ let errorMessageSet = errorSet || false;
+
+ for (let id in errors) {
+ if (!Array.isArray(errors[id]) && typeof errors[id] === 'object') {
+ errorMessageSet = vm.setValidationMessages(errors[id], errorMessageSet);
+ continue;
+ }
+
+ vm.components
+ .filter(component => component.category === 'input')
+ .filter(component => errors[component.state.id])
+ .forEach(component => {
+ errorMessageSet = true;
+
+ component.state._rejected = true;
+ component.state._isValid = false;
+ component.state._message = errors[component.state.id].join(' ');
+ });
+ }
+
+ return errorMessageSet;
+ };
+
+ vm.validate = () => {
+ let isValid = true;
+
+ for (let i = 0; i < vm.components.length; i++) {
+ if (vm.components[i].category !== 'input') {
+ continue;
+ }
+
+ if (!vm.components[i].state._isValid) {
+ isValid = false;
+ break;
+ }
+ }
+
+ return isValid;
+ };
+
+ vm.check = () => {
+ let isValid = vm.validate();
+
+ if (isValid !== vm.state.isValid) {
+ vm.state.isValid = isValid;
+ }
+ };
+
+ vm.deregisterInputGroup = components => {
+ for (let i = 0; i < components.length; i++) {
+ for (let j = 0; j < vm.components.length; j++) {
+ if (components[i] === vm.components[j].state) {
+ vm.components.splice(j, 1);
+ break;
+ }
+ }
+ }
+ };
+}
+
+AtFormController.$inject = ['EventService'];
+
+function atForm (pathService) {
+ return {
+ restrict: 'E',
+ replace: true,
+ transclude: true,
+ require: ['atForm'],
+ templateUrl: pathService.getPartialPath('components/form/form'),
+ controller: AtFormController,
+ controllerAs: 'vm',
+ link: atFormLink,
+ scope: {
+ state: '='
+ }
+ };
+}
+
+atForm.$inject = ['PathService'];
+
+export default atForm;
diff --git a/awx/ui/client/lib/components/form/form.partial.html b/awx/ui/client/lib/components/form/form.partial.html
new file mode 100644
index 0000000000..dd2d00b40e
--- /dev/null
+++ b/awx/ui/client/lib/components/form/form.partial.html
@@ -0,0 +1,9 @@
+
diff --git a/awx/ui/client/lib/components/index.js b/awx/ui/client/lib/components/index.js
new file mode 100644
index 0000000000..3f24c7376b
--- /dev/null
+++ b/awx/ui/client/lib/components/index.js
@@ -0,0 +1,52 @@
+import actionGroup from './action/action-group.directive';
+import divider from './utility/divider.directive';
+import form from './form/form.directive';
+import formAction from './form/action.directive';
+import inputCheckbox from './input/checkbox.directive';
+import inputGroup from './input/group.directive';
+import inputLabel from './input/label.directive';
+import inputLookup from './input/lookup.directive';
+import inputMessage from './input/message.directive';
+import inputNumber from './input/number.directive';
+import inputSelect from './input/select.directive';
+import inputSecret from './input/secret.directive';
+import inputText from './input/text.directive';
+import inputTextarea from './input/textarea.directive';
+import inputTextareaSecret from './input/textarea-secret.directive';
+import modal from './modal/modal.directive';
+import panel from './panel/panel.directive';
+import panelHeading from './panel/heading.directive';
+import panelBody from './panel/body.directive';
+import popover from './popover/popover.directive';
+import tab from './tabs/tab.directive';
+import tabGroup from './tabs/group.directive';
+
+import BaseInputController from './input/base.controller';
+
+angular
+ .module('at.lib.components', [])
+ .directive('atActionGroup', actionGroup)
+ .directive('atDivider', divider)
+ .directive('atForm', form)
+ .directive('atFormAction', formAction)
+ .directive('atInputCheckbox', inputCheckbox)
+ .directive('atInputGroup', inputGroup)
+ .directive('atInputLabel', inputLabel)
+ .directive('atInputLookup', inputLookup)
+ .directive('atInputMessage', inputMessage)
+ .directive('atInputNumber', inputNumber)
+ .directive('atInputSecret', inputSecret)
+ .directive('atInputSelect', inputSelect)
+ .directive('atInputText', inputText)
+ .directive('atInputTextarea', inputTextarea)
+ .directive('atInputTextareaSecret', inputTextareaSecret)
+ .directive('atModal', modal)
+ .directive('atPanel', panel)
+ .directive('atPanelHeading', panelHeading)
+ .directive('atPanelBody', panelBody)
+ .directive('atPopover', popover)
+ .directive('atTab', tab)
+ .directive('atTabGroup', tabGroup)
+ .service('BaseInputController', BaseInputController);
+
+
diff --git a/awx/ui/client/lib/components/input/_index.less b/awx/ui/client/lib/components/input/_index.less
new file mode 100644
index 0000000000..bc04b24246
--- /dev/null
+++ b/awx/ui/client/lib/components/input/_index.less
@@ -0,0 +1,208 @@
+.at-Input {
+ .at-mixin-Placeholder(@at-gray-dark-3x);
+
+ height: @at-input-height;
+ background: @at-white;
+ border-radius: @at-border-radius;
+ color: @at-gray-dark-5x;
+
+ &, &:active {
+ border-color: @at-gray-dark-2x;
+ }
+
+ &:focus {
+ border-color: @at-blue;
+ }
+}
+
+.at-InputCheckbox {
+ margin: 0;
+ padding: 0;
+
+ & > label {
+ & > input[type=checkbox] {
+ height: @at-input-height;
+ margin: 0;
+ padding: 0;
+ }
+
+ & > p {
+ margin: 0;
+ padding: 0 0 0 @at-space-6x;
+ line-height: @at-line-height-tall;
+ }
+ }
+}
+
+.at-InputContainer {
+ margin-top: @at-space-6x;
+}
+
+.at-Input-button {
+ min-width: @at-input-button-width;
+ display: block;
+ height: @at-input-height;
+
+ &, &:active, &:hover, &:focus {
+ color: @at-gray-dark-3x;
+ border-color: @at-gray-dark-2x;
+ background-color: @at-white;
+ cursor: pointer;
+ }
+}
+
+.at-Input--focus {
+ border-color: @at-blue;
+}
+
+.at-Input--rejected {
+ &, &:focus {
+ border-color: @at-red;
+ }
+}
+
+.at-InputFile--hidden {
+ position: absolute;
+ height: 100%;
+ width: 100%;
+ left: 0;
+ right: @at-input-button-width;
+ z-index: -2;
+ opacity: 0;
+}
+
+.at-InputFile--drag {
+ z-index: 3;
+}
+
+.at-InputGroup {
+ padding: 0;
+ margin: @at-space-6x 0 0 0;
+}
+
+.at-InputGroup-border {
+ position: absolute;
+ width: @at-inset-width;
+ height: 100%;
+ background: @at-gray-dark;
+ left: -@at-inset-width;
+}
+
+.at-InputGroup-button {
+ height: 100%;
+
+ & > button {
+ height: 100%;
+ }
+}
+
+.at-InputGroup-title {
+ .at-mixin-Heading(@at-font-size-2x);
+ margin: 0 0 0 @at-space-5x;
+}
+
+.at-InputGroup-divider {
+ clear: both;
+ margin: 0;
+ padding: 0;
+ height: @at-space-6x;
+}
+
+.at-InputLabel {
+ display: inline-block;
+ width: 100%;
+}
+
+.at-InputLabel-name {
+ color: @at-gray-dark-4x;
+ font-size: @at-font-size-2x;
+ font-weight: @at-font-weight;
+ text-transform: uppercase;
+}
+
+.at-InputLabel-hint {
+ margin-left: @at-space-4x;
+ color: @at-gray-dark-3x;
+ font-size: @at-font-size;
+ font-weight: @at-font-weight;
+ line-height: @at-line-height-short;
+}
+
+.at-InputLabel-checkbox {
+ margin: 0;
+ padding: 0;
+}
+
+.at-InputLabel-checkboxLabel {
+ margin-bottom: 0;
+
+ & > input[type=checkbox] {
+ margin: 0 @at-space 0 0;
+ position: relative;
+ top: @at-space;
+ }
+
+ & > p {
+ font-size: @at-font-size;
+ color: @at-gray-dark-4x;
+ font-weight: @at-font-weight;
+ display: inline;
+ margin: 0;
+ padding: 0;
+ }
+}
+
+.at-InputMessage--rejected {
+ font-size: @at-font-size;
+ color: @at-red;
+ margin: @at-space-3x 0 0 0;
+ padding: 0;
+}
+
+.at-InputLabel-required {
+ color: @at-red;
+ font-weight: @at-font-weight-2x;
+ font-size: @at-font-size-2x;
+ margin: 0;
+}
+
+.at-InputSelect {
+ position: relative;
+ width: 100%;
+
+ & > i {
+ font-size: @at-font-size;
+ position: absolute;
+ z-index: 3;
+ pointer-events: none;
+ top: @at-space-4x;
+ right: @at-space-4x;
+ color: @at-gray-dark-2x;
+ }
+}
+
+.at-InputSelect-input {
+ position: relative;
+ z-index: 2;
+ pointer-events: none;
+}
+
+.at-InputSelect-select {
+ height: @at-input-height;
+ cursor: pointer;
+ position: absolute;
+ z-index: 1;
+ top: 0;
+
+ & > optgroup {
+ text-transform: uppercase;
+
+ & > option {
+ text-transform: none;
+ }
+ }
+}
+
+.at-InputTextarea {
+ .at-mixin-FontFixedWidth();
+}
diff --git a/awx/ui/client/lib/components/input/base.controller.js b/awx/ui/client/lib/components/input/base.controller.js
new file mode 100644
index 0000000000..71591ca129
--- /dev/null
+++ b/awx/ui/client/lib/components/input/base.controller.js
@@ -0,0 +1,121 @@
+const REQUIRED_INPUT_MISSING_MESSAGE = 'Please enter a value.';
+const DEFAULT_INVALID_INPUT_MESSAGE = 'Invalid input for this type.';
+const PROMPT_ON_LAUNCH_VALUE = 'ASK';
+const ENCRYPTED_VALUE = '$encrypted$';
+
+function BaseInputController () {
+ return function extend (type, scope, element, form) {
+ let vm = this;
+
+ scope.state = scope.state || {};
+
+ scope.state._required = scope.state.required || false;
+ scope.state._isValid = scope.state.isValid || false;
+ scope.state._disabled = scope.state.disabled || false;
+ scope.state._activeModel = '_value';
+
+ if (scope.state.ask_at_runtime) {
+ scope.state._displayPromptOnLaunch = true;
+ }
+
+ if (scope.state._value) {
+ scope.state._edit = true;
+ scope.state._preEditValue = scope.state._value;
+
+ if (scope.state._value === PROMPT_ON_LAUNCH_VALUE) {
+ scope.state._promptOnLaunch = true;
+ scope.state._disabled = true;
+ scope.state._activeModel = '_displayValue';
+ }
+
+ if (scope.state._value === ENCRYPTED_VALUE) {
+ scope.state._displayRevertReplace = true;
+ scope.state._enableToggle = true;
+ scope.state._disabled = true;
+ scope.state._isBeingReplaced = false;
+ scope.state._activeModel = '_displayValue';
+ }
+ }
+
+ form.register(type, scope);
+
+ vm.validate = () => {
+ let isValid = true;
+ let message = '';
+
+ if (scope.state._required && !scope.state._value) {
+ isValid = false;
+ message = REQUIRED_INPUT_MISSING_MESSAGE;
+ }
+
+ if (scope.state.validate) {
+ let result = scope.state._validate(scope.state._value);
+
+ if (!result.isValid) {
+ isValid = false;
+ message = result.message || DEFAULT_INVALID_INPUT_MESSAGE;
+ }
+ }
+
+ return {
+ isValid,
+ message
+ };
+ };
+
+ vm.check = () => {
+ 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;
+
+ form.check();
+ }
+ };
+
+ vm.toggleRevertReplace = () => {
+ scope.state._isBeingReplaced = !scope.state._isBeingReplaced;
+
+ if (!scope.state._isBeingReplaced) {
+ scope.state._buttonText = 'REPLACE';
+ scope.state._disabled = true;
+ scope.state._enableToggle = true;
+ scope.state._value = scope.state._preEditValue;
+ scope.state._activeModel = '_displayValue';
+ scope.state._placeholder = 'ENCRYPTED';
+ } else {
+ scope.state._buttonText = 'REVERT';
+ scope.state._disabled = false;
+ scope.state._enableToggle = false;
+ scope.state._activeModel = '_value';
+ scope.state._value = '';
+ scope.state._placeholder = '';
+ }
+ };
+
+ vm.togglePromptOnLaunch = () => {
+ if (scope.state._promptOnLaunch) {
+ scope.state._value = PROMPT_ON_LAUNCH_VALUE;
+ scope.state._activeModel = '_displayValue';
+ scope.state._disabled = true;
+ scope.state._enableToggle = false;
+ } else {
+ if (scope.state._isBeingReplaced === false) {
+ scope.state._disabled = true;
+ scope.state._enableToggle = true;
+ scope.state._value = scope.state._preEditValue;
+ } else {
+ scope.state._activeModel = '_value';
+ scope.state._disabled = false;
+ scope.state._value = '';
+ }
+ }
+
+ vm.check();
+ };
+ };
+}
+
+export default BaseInputController;
diff --git a/awx/ui/client/lib/components/input/checkbox.directive.js b/awx/ui/client/lib/components/input/checkbox.directive.js
new file mode 100644
index 0000000000..9380eae846
--- /dev/null
+++ b/awx/ui/client/lib/components/input/checkbox.directive.js
@@ -0,0 +1,46 @@
+function atInputCheckboxLink (scope, element, attrs, controllers) {
+ let formController = controllers[0];
+ let inputController = controllers[1];
+
+ if (scope.tab === '1') {
+ element.find('input')[0].focus();
+ }
+
+ inputController.init(scope, element, formController);
+}
+
+function AtInputCheckboxController (baseInputController) {
+ let vm = this || {};
+
+ vm.init = (scope, element, form) => {
+ baseInputController.call(vm, 'input', scope, element, form);
+ scope.label = scope.state.label;
+ scope.state.label = 'OPTIONS';
+
+ vm.check();
+ };
+}
+
+AtInputCheckboxController.$inject = ['BaseInputController'];
+
+function atInputCheckbox (pathService) {
+ return {
+ restrict: 'E',
+ transclude: true,
+ replace: true,
+ require: ['^^atForm', 'atInputCheckbox'],
+ templateUrl: pathService.getPartialPath('components/input/checkbox'),
+ controller: AtInputCheckboxController,
+ controllerAs: 'vm',
+ link: atInputCheckboxLink,
+ scope: {
+ state: '=',
+ col: '@',
+ tab: '@'
+ }
+ };
+}
+
+atInputCheckbox.$inject = ['PathService'];
+
+export default atInputCheckbox;
diff --git a/awx/ui/client/lib/components/input/checkbox.partial.html b/awx/ui/client/lib/components/input/checkbox.partial.html
new file mode 100644
index 0000000000..df171e5cb5
--- /dev/null
+++ b/awx/ui/client/lib/components/input/checkbox.partial.html
@@ -0,0 +1,17 @@
+
diff --git a/awx/ui/client/lib/components/input/group.directive.js b/awx/ui/client/lib/components/input/group.directive.js
new file mode 100644
index 0000000000..5ffb8029b1
--- /dev/null
+++ b/awx/ui/client/lib/components/input/group.directive.js
@@ -0,0 +1,185 @@
+function atInputGroupLink (scope, el, attrs, controllers) {
+ let groupController = controllers[0];
+ let formController = controllers[1];
+ let element = el[0].getElementsByClassName('at-InputGroup-container')[0];
+
+ groupController.init(scope, formController, element);
+}
+
+function AtInputGroupController ($scope, $compile) {
+ let vm = this || {};
+
+ let form;
+ let scope;
+ let state;
+ let source;
+ let element;
+
+ vm.init = (_scope_, _form_, _element_) => {
+ form = _form_;
+ scope = _scope_;
+ element = _element_;
+ state = scope.state || {};
+ source = state._source;
+
+ $scope.$watch('state._source._value', vm.update);
+ };
+
+ vm.isValidSource = () => {
+ if (!source._value || source._value === state._value) {
+ return false;
+ }
+
+ return true;
+ };
+
+ vm.update = () => {
+ if (!vm.isValidSource()) {
+ return;
+ }
+
+ if (state._group) {
+ vm.clear();
+ }
+
+ state._value = source._value;
+
+ let inputs = state._get(source._value);
+ let group = vm.createComponentConfigs(inputs);
+
+ vm.insert(group);
+ state._group = group;
+ vm.compile(group);
+ };
+
+ vm.createComponentConfigs = inputs => {
+ let group = [];
+
+ inputs.forEach((input, i) => {
+ input = Object.assign(input, vm.getComponentType(input));
+
+ group.push(Object.assign({
+ _element: vm.createComponent(input, i),
+ _key: 'inputs',
+ _group: true,
+ _groupIndex: i
+ }, input));
+ });
+
+ return group;
+ };
+
+ vm.getComponentType = input => {
+ let config = {};
+
+ if (input.type === 'string') {
+ if (!input.multiline) {
+ if (input.secret) {
+ config._component = 'at-input-secret';
+ } else {
+ config._component = 'at-input-text';
+ }
+ } else {
+ config._expand = true;
+
+ if (input.secret) {
+ config._component = 'at-input-textarea-secret';
+ } else {
+ config._component = 'at-input-textarea';
+ }
+ }
+
+ if (input.format === 'ssh_private_key') {
+ config._format = 'ssh-key';
+ }
+ } else if (input.type === 'number') {
+ config._component = 'at-input-number';
+ } else if (input.type === 'boolean') {
+ config._component = 'at-input-checkbox';
+ } else if (input.choices) {
+ config._component = 'at-input-select';
+ config._format = 'array';
+ config._data = input.choices;
+ config._exp = 'index as choice for (index, choice) in state._data';
+ } else {
+ throw new Error('Unsupported input type: ' + input.type)
+ }
+
+ return config;
+ };
+
+ vm.insert = group => {
+ let container = document.createElement('div');
+ let col = 1;
+ let colPerRow = 12 / scope.col;
+ let isDivided = true;
+
+ group.forEach((input, i) => {
+ if (input._expand && !isDivided) {
+ container.appendChild(vm.createDivider()[0]);
+ }
+
+ container.appendChild(input._element[0]);
+
+ if ((input._expand || col % colPerRow === 0) && i !== group.length -1) {
+ container.appendChild(vm.createDivider()[0]);
+ isDivided = true;
+ col = 0;
+ } else {
+ isDivided = false;
+ }
+
+ col++;
+ });
+
+ element.appendChild(container);
+ };
+
+ vm.createComponent = (input, index) => {
+ let tabindex = Number(scope.tab) + index;
+ let col = input._expand ? 12 : scope.col;
+
+ return angular.element(
+ `<${input._component} col="${col}" tab="${tabindex}"
+ state="${state._reference}._group[${index}]">
+ ${input._component}>`
+ );
+ };
+
+ vm.createDivider = () => {
+ return angular.element('');
+ };
+
+ vm.compile = group => {
+ group.forEach(component => $compile(component._element[0])(scope.$parent));
+ };
+
+ vm.clear = () => {
+ form.deregisterInputGroup(state._group);
+ element.innerHTML = '';
+ };
+}
+
+AtInputGroupController.$inject = ['$scope', '$compile'];
+
+function atInputGroup (pathService) {
+ return {
+ restrict: 'E',
+ replace: true,
+ transclude: true,
+ require: ['atInputGroup', '^^atForm'],
+ templateUrl: pathService.getPartialPath('components/input/group'),
+ controller: AtInputGroupController,
+ controllerAs: 'vm',
+ link: atInputGroupLink,
+ scope: {
+ state: '=',
+ col: '@',
+ tab: '@'
+ }
+ };
+}
+
+atInputGroup.$inject = ['PathService'];
+
+export default atInputGroup;
diff --git a/awx/ui/client/lib/components/input/group.partial.html b/awx/ui/client/lib/components/input/group.partial.html
new file mode 100644
index 0000000000..6d20836d6a
--- /dev/null
+++ b/awx/ui/client/lib/components/input/group.partial.html
@@ -0,0 +1,13 @@
+
diff --git a/awx/ui/client/lib/components/input/label.directive.js b/awx/ui/client/lib/components/input/label.directive.js
new file mode 100644
index 0000000000..4837c25c14
--- /dev/null
+++ b/awx/ui/client/lib/components/input/label.directive.js
@@ -0,0 +1,11 @@
+function atInputLabel (pathService) {
+ return {
+ restrict: 'E',
+ replace: true,
+ templateUrl: pathService.getPartialPath('components/input/label')
+ };
+}
+
+atInputLabel.$inject = ['PathService'];
+
+export default atInputLabel;
diff --git a/awx/ui/client/lib/components/input/label.partial.html b/awx/ui/client/lib/components/input/label.partial.html
new file mode 100644
index 0000000000..d53a4a7a25
--- /dev/null
+++ b/awx/ui/client/lib/components/input/label.partial.html
@@ -0,0 +1,14 @@
+
diff --git a/awx/ui/client/lib/components/input/lookup.directive.js b/awx/ui/client/lib/components/input/lookup.directive.js
new file mode 100644
index 0000000000..950edeca39
--- /dev/null
+++ b/awx/ui/client/lib/components/input/lookup.directive.js
@@ -0,0 +1,70 @@
+function atInputLookupLink (scope, element, attrs, controllers) {
+ let formController = controllers[0];
+ let inputController = controllers[1];
+
+ if (scope.tab === '1') {
+ element.find('input')[0].focus();
+ }
+
+ inputController.init(scope, element, formController);
+}
+
+function AtInputLookupController (baseInputController) {
+ let vm = this || {};
+
+ vm.lookup = {};
+
+ vm.init = (scope, element, form) => {
+ baseInputController.call(vm, 'input', scope, element, form);
+
+ vm.lookup.modal = {
+ title: 'Select Organization',
+ buttons: [
+ {
+ type: 'cancel'
+ },
+ {
+ type: 'select'
+ }
+ ]
+ };
+
+ vm.lookup.search = {
+ placeholder: 'test'
+ };
+
+ vm.lookup.table = {
+
+ };
+
+ vm.check();
+ };
+
+ vm.search = () => {
+ vm.modal.show('test');
+ };
+}
+
+AtInputLookupController.$inject = ['BaseInputController'];
+
+function atInputLookup (pathService) {
+ return {
+ restrict: 'E',
+ transclude: true,
+ replace: true,
+ require: ['^^atForm', 'atInputLookup'],
+ templateUrl: pathService.getPartialPath('components/input/lookup'),
+ controller: AtInputLookupController,
+ controllerAs: 'vm',
+ link: atInputLookupLink,
+ scope: {
+ state: '=',
+ col: '@',
+ tab: '@'
+ }
+ };
+}
+
+atInputLookup.$inject = ['PathService'];
+
+export default atInputLookup;
diff --git a/awx/ui/client/lib/components/input/lookup.partial.html b/awx/ui/client/lib/components/input/lookup.partial.html
new file mode 100644
index 0000000000..d5f0ca89b7
--- /dev/null
+++ b/awx/ui/client/lib/components/input/lookup.partial.html
@@ -0,0 +1,30 @@
+
diff --git a/awx/ui/client/lib/components/input/message.directive.js b/awx/ui/client/lib/components/input/message.directive.js
new file mode 100644
index 0000000000..d3c06fdd57
--- /dev/null
+++ b/awx/ui/client/lib/components/input/message.directive.js
@@ -0,0 +1,11 @@
+function atInputMessage (pathService) {
+ return {
+ restrict: 'E',
+ replace: true,
+ templateUrl: pathService.getPartialPath('components/input/message'),
+ };
+}
+
+atInputMessage.$inject = ['PathService'];
+
+export default atInputMessage;
diff --git a/awx/ui/client/lib/components/input/message.partial.html b/awx/ui/client/lib/components/input/message.partial.html
new file mode 100644
index 0000000000..00951434a6
--- /dev/null
+++ b/awx/ui/client/lib/components/input/message.partial.html
@@ -0,0 +1,4 @@
+
+ {{ state._message }}
+
+
diff --git a/awx/ui/client/lib/components/input/number.directive.js b/awx/ui/client/lib/components/input/number.directive.js
new file mode 100644
index 0000000000..be803212de
--- /dev/null
+++ b/awx/ui/client/lib/components/input/number.directive.js
@@ -0,0 +1,54 @@
+const DEFAULT_STEP = '1';
+const DEFAULT_MIN = '0';
+const DEFAULT_MAX = '1000000000';
+const DEFAULT_PLACEHOLDER = '';
+
+function atInputNumberLink (scope, element, attrs, controllers) {
+ let formController = controllers[0];
+ let inputController = controllers[1];
+
+ if (scope.tab === '1') {
+ element.find('input')[0].focus();
+ }
+
+ inputController.init(scope, element, formController);
+}
+
+function AtInputNumberController (baseInputController) {
+ let vm = this || {};
+
+ vm.init = (scope, element, form) => {
+ baseInputController.call(vm, 'input', scope, element, form);
+
+ scope.state._step = scope.state._step || DEFAULT_STEP;
+ scope.state._min = scope.state._min || DEFAULT_MIN;
+ scope.state._max = scope.state._max || DEFAULT_MAX;
+ scope.state._placeholder = scope.state._placeholder || DEFAULT_PLACEHOLDER;
+
+ vm.check();
+ };
+}
+
+AtInputNumberController.$inject = ['BaseInputController'];
+
+function atInputNumber (pathService) {
+ return {
+ restrict: 'E',
+ transclude: true,
+ replace: true,
+ require: ['^^atForm', 'atInputNumber'],
+ templateUrl: pathService.getPartialPath('components/input/number'),
+ controller: AtInputNumberController,
+ controllerAs: 'vm',
+ link: atInputNumberLink,
+ scope: {
+ state: '=',
+ col: '@',
+ tab: '@'
+ }
+ };
+}
+
+atInputNumber.$inject = ['PathService'];
+
+export default atInputNumber;
diff --git a/awx/ui/client/lib/components/input/number.partial.html b/awx/ui/client/lib/components/input/number.partial.html
new file mode 100644
index 0000000000..57aa355bfa
--- /dev/null
+++ b/awx/ui/client/lib/components/input/number.partial.html
@@ -0,0 +1,19 @@
+
diff --git a/awx/ui/client/lib/components/input/secret.directive.js b/awx/ui/client/lib/components/input/secret.directive.js
new file mode 100644
index 0000000000..b7ba061bda
--- /dev/null
+++ b/awx/ui/client/lib/components/input/secret.directive.js
@@ -0,0 +1,69 @@
+function atInputSecretLink (scope, element, attrs, controllers) {
+ let formController = controllers[0];
+ let inputController = controllers[1];
+
+ if (scope.tab === '1') {
+ element.find('input')[0].focus();
+ }
+
+ inputController.init(scope, element, formController);
+}
+
+function AtInputSecretController (baseInputController) {
+ let vm = this || {};
+
+ let scope;
+
+ vm.init = (_scope_, element, form) => {
+ baseInputController.call(vm, 'input', _scope_, element, form);
+
+ scope = _scope_;
+
+ if (!scope.state._value || scope.state._promptOnLaunch) {
+ scope.state._buttonText = 'SHOW';
+ scope.type = 'password';
+
+ vm.toggle = vm.toggleShowHide;
+ } else {
+ scope.state._buttonText = 'REPLACE';
+ scope.state._placeholder = 'ENCRYPTED';
+ vm.toggle = vm.toggleRevertReplace;
+ }
+
+ vm.check();
+ };
+
+ vm.toggleShowHide = () => {
+ if (scope.type === 'password') {
+ scope.type = 'text';
+ scope.state._buttonText = 'HIDE';
+ } else {
+ scope.type = 'password';
+ scope.state._buttonText = 'SHOW';
+ }
+ };
+}
+
+AtInputSecretController.$inject = ['BaseInputController'];
+
+function atInputSecret (pathService) {
+ return {
+ restrict: 'E',
+ transclude: true,
+ replace: true,
+ require: ['^^atForm', 'atInputSecret'],
+ templateUrl: pathService.getPartialPath('components/input/secret'),
+ controller: AtInputSecretController,
+ controllerAs: 'vm',
+ link: atInputSecretLink,
+ scope: {
+ state: '=',
+ col: '@',
+ tab: '@'
+ }
+ };
+}
+
+atInputSecret.$inject = ['PathService'];
+
+export default atInputSecret;
diff --git a/awx/ui/client/lib/components/input/secret.partial.html b/awx/ui/client/lib/components/input/secret.partial.html
new file mode 100644
index 0000000000..9102c59b0d
--- /dev/null
+++ b/awx/ui/client/lib/components/input/secret.partial.html
@@ -0,0 +1,26 @@
+
diff --git a/awx/ui/client/lib/components/input/select.directive.js b/awx/ui/client/lib/components/input/select.directive.js
new file mode 100644
index 0000000000..cbc6c2b0fa
--- /dev/null
+++ b/awx/ui/client/lib/components/input/select.directive.js
@@ -0,0 +1,97 @@
+const DEFAULT_EMPTY_PLACEHOLDER = 'NO OPTIONS AVAILABLE';
+
+function atInputSelectLink (scope, element, attrs, controllers) {
+ let formController = controllers[0];
+ let inputController = controllers[1];
+
+ if (scope.tab === '1') {
+ elements.select.focus();
+ }
+
+ inputController.init(scope, element, formController);
+}
+
+function AtInputSelectController (baseInputController, eventService) {
+ let vm = this || {};
+
+ let scope;
+ let element;
+ let input;
+ let select;
+
+ vm.init = (_scope_, _element_, form) => {
+ baseInputController.call(vm, 'input', _scope_, _element_, form);
+
+ scope = _scope_;
+ element = _element_;
+ input = element.find('input')[0];
+ select = element.find('select')[0];
+
+ if (!scope.state._data || scope.state._data.length === 0) {
+ scope.state._disabled = true;
+ scope.state._placeholder = DEFAULT_EMPTY_PLACEHOLDER;
+ }
+
+ vm.setListeners();
+ vm.check();
+
+ if (scope.state._value) {
+ vm.updateDisplayModel();
+ }
+ };
+
+ vm.setListeners = () => {
+ let listeners = eventService.addListeners([
+ [input, 'focus', () => select.focus],
+ [select, 'mousedown', () => scope.$apply(() => scope.open = !scope.open)],
+ [select, 'focus', () => input.classList.add('at-Input--focus')],
+ [select, 'change', () => scope.$apply(() => {
+ scope.open = false;
+ vm.updateDisplayModel();
+ vm.check();
+ })],
+ [select, 'blur', () => {
+ input.classList.remove('at-Input--focus');
+ scope.open = scope.open && false;
+ }]
+ ]);
+
+ scope.$on('$destroy', () => eventService.remove(listeners));
+ };
+
+ vm.updateDisplayModel = () => {
+ if (scope.state._format === 'array') {
+ scope.displayModel = scope.state._data[scope.state._value];
+ } else if (scope.state._format === 'objects') {
+ scope.displayModel = scope.state._value[scope.state._display];
+ } else if (scope.state._format === 'grouped-object') {
+ scope.displayModel = scope.state._value[scope.state._display];
+ } else {
+ throw new Error('Unsupported display model type');
+ }
+ };
+}
+
+AtInputSelectController.$inject = ['BaseInputController', 'EventService'];
+
+function atInputSelect (pathService) {
+ return {
+ restrict: 'E',
+ transclude: true,
+ replace: true,
+ require: ['^^at-form', 'atInputSelect'],
+ templateUrl: pathService.getPartialPath('components/input/select'),
+ controller: AtInputSelectController,
+ controllerAs: 'vm',
+ link: atInputSelectLink,
+ scope: {
+ state: '=',
+ col: '@',
+ tab: '@'
+ }
+ };
+}
+
+atInputSelect.$inject = ['PathService'];
+
+export default atInputSelect;
diff --git a/awx/ui/client/lib/components/input/select.partial.html b/awx/ui/client/lib/components/input/select.partial.html
new file mode 100644
index 0000000000..aaa31bebee
--- /dev/null
+++ b/awx/ui/client/lib/components/input/select.partial.html
@@ -0,0 +1,26 @@
+
diff --git a/awx/ui/client/lib/components/input/text.directive.js b/awx/ui/client/lib/components/input/text.directive.js
new file mode 100644
index 0000000000..0135bd8841
--- /dev/null
+++ b/awx/ui/client/lib/components/input/text.directive.js
@@ -0,0 +1,44 @@
+function atInputTextLink (scope, element, attrs, controllers) {
+ let formController = controllers[0];
+ let inputController = controllers[1];
+
+ if (scope.tab === '1') {
+ element.find('input')[0].focus();
+ }
+
+ inputController.init(scope, element, formController);
+}
+
+function AtInputTextController (baseInputController) {
+ let vm = this || {};
+
+ vm.init = (scope, element, form) => {
+ baseInputController.call(vm, 'input', scope, element, form);
+
+ vm.check();
+ };
+}
+
+AtInputTextController.$inject = ['BaseInputController'];
+
+function atInputText (pathService) {
+ return {
+ restrict: 'E',
+ transclude: true,
+ replace: true,
+ require: ['^^atForm', 'atInputText'],
+ templateUrl: pathService.getPartialPath('components/input/text'),
+ controller: AtInputTextController,
+ controllerAs: 'vm',
+ link: atInputTextLink,
+ scope: {
+ state: '=',
+ col: '@',
+ tab: '@'
+ }
+ };
+}
+
+atInputText.$inject = ['PathService'];
+
+export default atInputText;
diff --git a/awx/ui/client/lib/components/input/text.partial.html b/awx/ui/client/lib/components/input/text.partial.html
new file mode 100644
index 0000000000..c5140df834
--- /dev/null
+++ b/awx/ui/client/lib/components/input/text.partial.html
@@ -0,0 +1,16 @@
+
diff --git a/awx/ui/client/lib/components/input/textarea-secret.directive.js b/awx/ui/client/lib/components/input/textarea-secret.directive.js
new file mode 100644
index 0000000000..a22491b3de
--- /dev/null
+++ b/awx/ui/client/lib/components/input/textarea-secret.directive.js
@@ -0,0 +1,117 @@
+const DEFAULT_HINT = 'HINT: Drag and drop an SSH private key file on the field below.';
+
+function atInputTextareaSecretLink (scope, element, attrs, controllers) {
+ let formController = controllers[0];
+ let inputController = controllers[1];
+
+ if (scope.tab === '1') {
+ element.find('textarea')[0].focus();
+ }
+
+ inputController.init(scope, element, formController);
+}
+
+function AtInputTextareaSecretController (baseInputController, eventService) {
+ let vm = this || {};
+
+ let scope;
+ let textarea;
+ let container;
+ let input;
+
+ vm.init = (_scope_, element, form) => {
+ baseInputController.call(vm, 'input', _scope_, element, form);
+
+ scope = _scope_;
+ textarea = element.find('textarea')[0];
+ container = element[0];
+
+ if (scope.state.format === 'ssh_private_key') {
+ scope.ssh = true;
+ scope.state._hint = scope.state._hint || DEFAULT_HINT;
+ input = element.find('input')[0];
+ }
+
+ if (scope.state._value) {
+ scope.state._buttonText = 'REPLACE';
+ scope.state._placeholder = 'ENCRYPTED';
+ } else {
+ if (scope.state.format === 'ssh_private_key') {
+ vm.listeners = vm.setFileListeners(textarea, input);
+ scope.state._displayHint = true;
+ }
+ }
+
+ vm.check();
+ };
+
+ vm.toggle = () => {
+ vm.toggleRevertReplace();
+
+ if (scope.state._isBeingReplaced) {
+ scope.state._placeholder = '';
+ scope.state._displayHint = true;
+ vm.listeners = vm.setFileListeners(textarea, input);
+ } else {
+ scope.state._displayHint = false;
+ scope.state._placeholder = 'ENCRYPTED';
+ eventService.remove(vm.listeners);
+ }
+ };
+
+ vm.setFileListeners = (textarea, input) => {
+ return eventService.addListeners([
+ [textarea, 'dragenter', event => {
+ event.stopPropagation();
+ event.preventDefault();
+ scope.$apply(() => scope.drag = true);
+ }],
+
+ [input, 'dragleave', event => {
+ event.stopPropagation();
+ event.preventDefault();
+ scope.$apply(() => scope.drag = false);
+ }],
+
+ [input, 'change', event => {
+ let reader = new FileReader();
+
+ reader.onload = () => vm.readFile(reader, event);
+ reader.readAsText(input.files[0]);
+ }]
+ ]);
+ };
+
+ vm.readFile = (reader, event) => {
+ scope.$apply(() => {
+ scope.state._value = reader.result;
+ vm.check();
+ scope.drag = false
+ input.value = '';
+ });
+ };
+}
+
+AtInputTextareaSecretController.$inject = ['BaseInputController', 'EventService'];
+
+function atInputTextareaSecret (pathService) {
+ return {
+ restrict: 'E',
+ transclude: true,
+ replace: true,
+ require: ['^^atForm', 'atInputTextareaSecret'],
+ templateUrl: pathService.getPartialPath('components/input/textarea-secret'),
+ controller: AtInputTextareaSecretController,
+ controllerAs: 'vm',
+ link: atInputTextareaSecretLink,
+ scope: {
+ state: '=',
+ col: '@',
+ tab: '@'
+ }
+ };
+}
+
+atInputTextareaSecret.$inject = ['PathService'];
+
+export default atInputTextareaSecret;
diff --git a/awx/ui/client/lib/components/input/textarea-secret.partial.html b/awx/ui/client/lib/components/input/textarea-secret.partial.html
new file mode 100644
index 0000000000..dd0bc6e5ac
--- /dev/null
+++ b/awx/ui/client/lib/components/input/textarea-secret.partial.html
@@ -0,0 +1,32 @@
+
diff --git a/awx/ui/client/lib/components/input/textarea.directive.js b/awx/ui/client/lib/components/input/textarea.directive.js
new file mode 100644
index 0000000000..b9ee4eb60b
--- /dev/null
+++ b/awx/ui/client/lib/components/input/textarea.directive.js
@@ -0,0 +1,44 @@
+function atInputTextareaLink (scope, element, attrs, controllers) {
+ let formController = controllers[0];
+ let inputController = controllers[1];
+
+ if (scope.tab === '1') {
+ element.find('input')[0].focus();
+ }
+
+ inputController.init(scope, element, formController);
+}
+
+function AtInputTextareaController (baseInputController) {
+ let vm = this || {};
+
+ vm.init = (scope, element, form) => {
+ baseInputController.call(vm, 'input', scope, element, form);
+
+ vm.check();
+ };
+}
+
+AtInputTextareaController.$inject = ['BaseInputController'];
+
+function atInputTextarea (pathService) {
+ return {
+ restrict: 'E',
+ transclude: true,
+ replace: true,
+ require: ['^^atForm', 'atInputTextarea'],
+ templateUrl: pathService.getPartialPath('components/input/textarea'),
+ controller: AtInputTextareaController,
+ controllerAs: 'vm',
+ link: atInputTextareaLink,
+ scope: {
+ state: '=',
+ col: '@',
+ tab: '@'
+ }
+ };
+}
+
+atInputTextarea.$inject = ['PathService'];
+
+export default atInputTextarea;
diff --git a/awx/ui/client/lib/components/input/textarea.partial.html b/awx/ui/client/lib/components/input/textarea.partial.html
new file mode 100644
index 0000000000..bc0738dc9f
--- /dev/null
+++ b/awx/ui/client/lib/components/input/textarea.partial.html
@@ -0,0 +1,17 @@
+
diff --git a/awx/ui/client/lib/components/modal/_index.less b/awx/ui/client/lib/components/modal/_index.less
new file mode 100644
index 0000000000..11e962e98b
--- /dev/null
+++ b/awx/ui/client/lib/components/modal/_index.less
@@ -0,0 +1,10 @@
+.at-Modal-title {
+ margin: 0;
+ padding: 0;
+
+ .at-mixin-Heading(@at-font-size-3x);
+}
+
+.at-Modal-body {
+ font-size: @at-font-size;
+}
diff --git a/awx/ui/client/lib/components/modal/modal.directive.js b/awx/ui/client/lib/components/modal/modal.directive.js
new file mode 100644
index 0000000000..10f18a0afc
--- /dev/null
+++ b/awx/ui/client/lib/components/modal/modal.directive.js
@@ -0,0 +1,63 @@
+const DEFAULT_ANIMATION_DURATION = 150;
+
+function atModalLink (scope, el, attr, controllers) {
+ let modalController = controllers[0];
+ let container = el[0];
+
+ modalController.init(scope, container);
+}
+
+function AtModalController () {
+ let vm = this;
+
+ let scope;
+ let container;
+
+ vm.init = (_scope_, _container_) => {
+ scope = _scope_;
+ container = _container_;
+
+ scope.state.show = vm.show;
+ scope.state.hide = vm.hide;
+ };
+
+ vm.show = (title, message) => {
+ scope.title = title;
+ scope.message = message;
+
+ container.style.display = 'block';
+ container.style.opacity = 1;
+ };
+
+ vm.hide = () => {
+ container.style.opacity = 0;
+
+ setTimeout(() => {
+ container.style.display = 'none';
+ scope.message = '';
+ scope.title = '';
+ }, DEFAULT_ANIMATION_DURATION);
+ };
+}
+
+function atModal (pathService) {
+ return {
+ restrict: 'E',
+ replace: true,
+ transclude: true,
+ require: ['atModal'],
+ templateUrl: pathService.getPartialPath('components/modal/modal'),
+ controller: AtModalController,
+ controllerAs: 'vm',
+ link: atModalLink,
+ scope: {
+ state: '='
+ }
+ };
+}
+
+atModal.$inject = [
+ 'PathService'
+];
+
+export default atModal;
diff --git a/awx/ui/client/lib/components/modal/modal.partial.html b/awx/ui/client/lib/components/modal/modal.partial.html
new file mode 100644
index 0000000000..9d96d1ff2a
--- /dev/null
+++ b/awx/ui/client/lib/components/modal/modal.partial.html
@@ -0,0 +1,21 @@
+
diff --git a/awx/ui/client/lib/components/panel/_index.less b/awx/ui/client/lib/components/panel/_index.less
new file mode 100644
index 0000000000..dd5e8cc234
--- /dev/null
+++ b/awx/ui/client/lib/components/panel/_index.less
@@ -0,0 +1,25 @@
+.at-Panel {
+ margin: @at-space-6x 0 0 0;
+ padding: @at-space-6x;
+ border-color: @at-gray-dark;
+}
+
+.at-Panel-heading {
+ margin: 0;
+ padding: 0;
+}
+
+.at-Panel-dismiss {
+ .at-mixin-ButtonIcon();
+ text-align: right;
+}
+
+.at-Panel-body {
+ margin: 0;
+ padding: 0;
+}
+
+.at-Panel-headingTitle {
+ .at-mixin-Heading(@at-font-size-3x);
+ text-transform: none;
+}
diff --git a/awx/ui/client/lib/components/panel/body.directive.js b/awx/ui/client/lib/components/panel/body.directive.js
new file mode 100644
index 0000000000..6011a81d92
--- /dev/null
+++ b/awx/ui/client/lib/components/panel/body.directive.js
@@ -0,0 +1,15 @@
+function atPanelBody (pathService) {
+ return {
+ restrict: 'E',
+ replace: true,
+ transclude: true,
+ templateUrl: pathService.getPartialPath('components/panel/body'),
+ scope: {
+ state: '='
+ }
+ };
+}
+
+atPanelBody.$inject = ['PathService'];
+
+export default atPanelBody;
diff --git a/awx/ui/client/lib/components/panel/body.partial.html b/awx/ui/client/lib/components/panel/body.partial.html
new file mode 100644
index 0000000000..371d08af3e
--- /dev/null
+++ b/awx/ui/client/lib/components/panel/body.partial.html
@@ -0,0 +1,3 @@
+
+
+
diff --git a/awx/ui/client/lib/components/panel/heading.directive.js b/awx/ui/client/lib/components/panel/heading.directive.js
new file mode 100644
index 0000000000..0ce8b2aee6
--- /dev/null
+++ b/awx/ui/client/lib/components/panel/heading.directive.js
@@ -0,0 +1,18 @@
+function link (scope, el, attrs, panel) {
+ panel.use(scope);
+}
+
+function atPanelHeading (pathService) {
+ return {
+ restrict: 'E',
+ require: '^^atPanel',
+ replace: true,
+ transclude: true,
+ templateUrl: pathService.getPartialPath('components/panel/heading'),
+ link
+ };
+}
+
+atPanelHeading.$inject = ['PathService'];
+
+export default atPanelHeading;
diff --git a/awx/ui/client/lib/components/panel/heading.partial.html b/awx/ui/client/lib/components/panel/heading.partial.html
new file mode 100644
index 0000000000..7026a3f11a
--- /dev/null
+++ b/awx/ui/client/lib/components/panel/heading.partial.html
@@ -0,0 +1,12 @@
+
diff --git a/awx/ui/client/lib/components/panel/panel.directive.js b/awx/ui/client/lib/components/panel/panel.directive.js
new file mode 100644
index 0000000000..7f3c0abfbf
--- /dev/null
+++ b/awx/ui/client/lib/components/panel/panel.directive.js
@@ -0,0 +1,48 @@
+function atPanelLink (scope, el, attrs, controllers) {
+ let panelController = controllers[0];
+
+ panelController.init(scope, el);
+}
+
+function AtPanelController ($state) {
+ let vm = this;
+
+ let scope;
+ let el;
+
+ vm.init = (_scope_, _el_) => {
+ scope = _scope_;
+ el = _el_;
+ };
+
+ vm.dismiss = () => {
+ $state.go('^');
+ };
+
+ vm.use = child => {
+ child.dismiss = vm.dismiss;
+ };
+}
+
+AtPanelController.$inject = ['$state'];
+
+function atPanel (pathService, _$animate_) {
+ return {
+ restrict: 'E',
+ replace: true,
+ require: ['atPanel'],
+ transclude: true,
+ templateUrl: pathService.getPartialPath('components/panel/panel'),
+ controller: AtPanelController,
+ controllerAs: 'vm',
+ link: atPanelLink,
+ scope: {
+ state: '=',
+ animate: '@'
+ }
+ };
+}
+
+atPanel.$inject = ['PathService'];
+
+export default atPanel;
diff --git a/awx/ui/client/lib/components/panel/panel.partial.html b/awx/ui/client/lib/components/panel/panel.partial.html
new file mode 100644
index 0000000000..476653e390
--- /dev/null
+++ b/awx/ui/client/lib/components/panel/panel.partial.html
@@ -0,0 +1,3 @@
+
+
+
diff --git a/awx/ui/client/lib/components/popover/_index.less b/awx/ui/client/lib/components/popover/_index.less
new file mode 100644
index 0000000000..dd40fdd99a
--- /dev/null
+++ b/awx/ui/client/lib/components/popover/_index.less
@@ -0,0 +1,51 @@
+.at-Popover {
+ padding: 0 0 0 @at-space-3x;
+}
+
+.at-Popover--inline {
+ display: inline-block;
+}
+
+.at-Popover-icon {
+ .at-mixin-ButtonIcon();
+ font-size: @at-font-size-4x;
+ padding: 0;
+ margin: 0;
+}
+
+.at-Popover-container {
+ visibility: hidden;
+ opacity: 0;
+ color: @at-white;
+ background-color: @at-gray-dark-4x;
+ max-width: @at-popover-width;
+ padding: @at-space-4x;
+ height: auto;
+ position: fixed;
+ z-index: 2000;
+ margin: 0 0 0 @at-space-6x;
+ border-radius: @at-border-radius;
+ box-shadow: 0 5px 10px rgba(0,0,0, 0.2);
+ transition: opacity .15s linear;
+ font-weight: @at-font-weight
+}
+
+.at-Popover-arrow {
+ color: @at-gray-dark-4x;
+ position: fixed;
+ z-index: 1999;
+ padding: 0;
+ margin: @at-space-4x 0 0 @at-space;
+}
+
+.at-Popover-title {
+ .at-mixin-Heading(@at-font-size);
+ color: @at-white;
+ margin-bottom: @at-space-4x;
+}
+
+.at-Popover-text {
+ margin: 0;
+ padding: 0;
+ font-size: @at-font-size;
+}
diff --git a/awx/ui/client/lib/components/popover/popover.directive.js b/awx/ui/client/lib/components/popover/popover.directive.js
new file mode 100644
index 0000000000..66857f6e4c
--- /dev/null
+++ b/awx/ui/client/lib/components/popover/popover.directive.js
@@ -0,0 +1,118 @@
+function atPopoverLink (scope, el, attr, controllers) {
+ let popoverController = controllers[0];
+ let container = el[0];
+ let popover = container.getElementsByClassName('at-Popover-container')[0];
+ let icon = container.getElementsByTagName('i')[0];
+
+ popoverController.init(scope, container, icon, popover);
+}
+
+function AtPopoverController () {
+ let vm = this;
+
+ let container;
+ let icon;
+ let popover;
+
+ vm.init = (scope, _container_, _icon_, _popover_) => {
+ icon = _icon_;
+ popover = _popover_;
+ scope.inline = true;
+
+ icon.addEventListener('click', vm.createDisplayListener());
+ };
+
+ vm.createDismissListener = (createEvent) => {
+ return event => {
+ event.stopPropagation();
+
+ if (vm.isClickWithinPopover(event, popover)) {
+ return;
+ }
+
+ vm.open = false;
+
+ popover.style.visibility = 'hidden';
+ popover.style.opacity = 0;
+
+ window.removeEventListener('click', vm.dismissListener);
+ window.removeEventListener('resize', vm.dismissListener);
+ };
+ };
+
+ vm.isClickWithinPopover = (event, popover) => {
+ let box = popover.getBoundingClientRect();
+
+ let x = event.clientX;
+ let y = event.clientY;
+
+ if ((x <= box.right && x >= box.left) && (y >= box.top && y <= box.bottom)) {
+ return true;
+ }
+
+ return false;
+ };
+
+ vm.createDisplayListener = () => {
+ return event => {
+ if (vm.open) {
+ return;
+ }
+
+ event.stopPropagation();
+
+ vm.open = true;
+
+ let arrow = popover.getElementsByClassName('at-Popover-arrow')[0];
+
+ let iPos = icon.getBoundingClientRect();
+ let pPos = popover.getBoundingClientRect();
+
+ let wHeight = window.clientHeight;
+ let pHeight = pPos.height;
+
+ let cx = Math.floor(iPos.left + (iPos.width / 2));
+ let cy = Math.floor(iPos.top + (iPos.height / 2));
+
+ arrow.style.top = (iPos.top - iPos.height) + 'px';
+ arrow.style.left = iPos.right + 'px';
+
+ if (cy < (pHeight / 2)) {
+ popover.style.top = '10px';
+ } else {
+ popover.style.top = (cy - pHeight / 2) + 'px';
+ }
+
+ popover.style.left = cx + 'px';
+ popover.style.visibility = 'visible';
+ popover.style.opacity = 1;
+
+ vm.dismissListener = vm.createDismissListener(event);
+
+ window.addEventListener('click', vm.dismissListener);
+ window.addEventListener('resize', vm.dismissListener);
+ };
+ };
+}
+
+function atPopover (pathService) {
+ return {
+ restrict: 'E',
+ replace: true,
+ transclude: true,
+ require: ['atPopover'],
+ templateUrl: pathService.getPartialPath('components/popover/popover'),
+ controller: AtPopoverController,
+ controllerAs: 'vm',
+ link: atPopoverLink,
+ scope: {
+ state: '='
+ }
+ };
+}
+
+atPopover.$inject = [
+ 'PathService'
+];
+
+export default atPopover;
diff --git a/awx/ui/client/lib/components/popover/popover.partial.html b/awx/ui/client/lib/components/popover/popover.partial.html
new file mode 100644
index 0000000000..f8acff1c84
--- /dev/null
+++ b/awx/ui/client/lib/components/popover/popover.partial.html
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+
{{::state.label}}
+
{{::state.help_text}}
+
+
+
diff --git a/awx/ui/client/lib/components/tabs/_index.less b/awx/ui/client/lib/components/tabs/_index.less
new file mode 100644
index 0000000000..a95293942a
--- /dev/null
+++ b/awx/ui/client/lib/components/tabs/_index.less
@@ -0,0 +1,27 @@
+.at-TabGroup {
+ margin-top: @at-space-6x;
+}
+
+.at-Tab {
+ margin: 0 @at-space-5x 0 0;
+ font-size: @at-font-size;
+}
+
+.at-Tab--active {
+ &, &:hover, &:active, &:focus {
+ color: @at-white;
+ background-color: @at-gray-dark-3x;
+ border-color: @at-gray-dark-3x;
+ cursor: default;
+ }
+}
+
+.at-Tab--disabled {
+ &, &:hover, &:active, &:focus {
+ background-color: @at-white;
+ color: @at-gray-dark-2x;
+ border-color: @at-gray-dark-2x;
+ opacity: 0.65;
+ cursor: not-allowed;
+ }
+}
diff --git a/awx/ui/client/lib/components/tabs/group.directive.js b/awx/ui/client/lib/components/tabs/group.directive.js
new file mode 100644
index 0000000000..a78da714cf
--- /dev/null
+++ b/awx/ui/client/lib/components/tabs/group.directive.js
@@ -0,0 +1,55 @@
+function atTabGroupLink (scope, el, attrs, controllers) {
+ let groupController = controllers[0];
+
+ groupController.init(scope, el);
+}
+
+function AtTabGroupController ($state) {
+ let vm = this;
+
+ vm.tabs = [];
+
+ let scope;
+ let el;
+
+ vm.init = (_scope_, _el_) => {
+ scope = _scope_;
+ el = _el_;
+ };
+
+ vm.register = tab => {
+
+ tab.active = true;
+/*
+ * if (vm.tabs.length === 0) {
+ * tab.active = true;
+ * } else {
+ * tab.disabled = true;
+ * }
+ *
+ */
+ vm.tabs.push(tab);
+ };
+}
+
+AtTabGroupController.$inject = ['$state'];
+
+function atTabGroup (pathService, _$animate_) {
+ return {
+ restrict: 'E',
+ replace: true,
+ require: ['atTabGroup'],
+ transclude: true,
+ templateUrl: pathService.getPartialPath('components/tabs/group'),
+ controller: AtTabGroupController,
+ controllerAs: 'vm',
+ link: atTabGroupLink,
+ scope: {
+ state: '='
+ }
+ };
+}
+
+atTabGroup.$inject = ['PathService'];
+
+export default atTabGroup;
diff --git a/awx/ui/client/lib/components/tabs/group.partial.html b/awx/ui/client/lib/components/tabs/group.partial.html
new file mode 100644
index 0000000000..8f4e538da4
--- /dev/null
+++ b/awx/ui/client/lib/components/tabs/group.partial.html
@@ -0,0 +1,3 @@
+
+
+
diff --git a/awx/ui/client/lib/components/tabs/tab.directive.js b/awx/ui/client/lib/components/tabs/tab.directive.js
new file mode 100644
index 0000000000..99e181dfca
--- /dev/null
+++ b/awx/ui/client/lib/components/tabs/tab.directive.js
@@ -0,0 +1,52 @@
+function atTabLink (scope, el, attrs, controllers) {
+ let groupController = controllers[0];
+ let tabController = controllers[1];
+
+ tabController.init(scope, el, groupController);
+}
+
+function AtTabController ($state) {
+ let vm = this;
+
+ let scope;
+ let el;
+ let group;
+
+ vm.init = (_scope_, _el_, _group_) => {
+ scope = _scope_;
+ el = _el_;
+ group = _group_;
+
+ group.register(scope);
+ };
+
+ vm.go = () => {
+ if (scope.state._disabled || scope.state._active) {
+ return;
+ }
+
+ $state.go(scope.state._go, scope.state._params, { reload: true });
+ };
+}
+
+AtTabController.$inject = ['$state'];
+
+function atTab (pathService, _$animate_) {
+ return {
+ restrict: 'E',
+ replace: true,
+ transclude: true,
+ require: ['^^atTabGroup', 'atTab'],
+ templateUrl: pathService.getPartialPath('components/tabs/tab'),
+ controller: AtTabController,
+ controllerAs: 'vm',
+ link: atTabLink,
+ scope: {
+ state: '='
+ }
+ };
+}
+
+atTab.$inject = ['PathService'];
+
+export default atTab;
diff --git a/awx/ui/client/lib/components/tabs/tab.partial.html b/awx/ui/client/lib/components/tabs/tab.partial.html
new file mode 100644
index 0000000000..eb8ea75f11
--- /dev/null
+++ b/awx/ui/client/lib/components/tabs/tab.partial.html
@@ -0,0 +1,6 @@
+
diff --git a/awx/ui/client/lib/components/utility/_index.less b/awx/ui/client/lib/components/utility/_index.less
new file mode 100644
index 0000000000..f9851bef2b
--- /dev/null
+++ b/awx/ui/client/lib/components/utility/_index.less
@@ -0,0 +1,5 @@
+.at-Divider {
+ clear: both;
+ margin: 0;
+ padding: 0;
+}
diff --git a/awx/ui/client/lib/components/utility/divider.directive.js b/awx/ui/client/lib/components/utility/divider.directive.js
new file mode 100644
index 0000000000..0ccb906996
--- /dev/null
+++ b/awx/ui/client/lib/components/utility/divider.directive.js
@@ -0,0 +1,12 @@
+function atPanelBody (pathService) {
+ return {
+ restrict: 'E',
+ replace: true,
+ templateUrl: pathService.getPartialPath('components/utility/divider'),
+ scope: false
+ };
+}
+
+atPanelBody.$inject = ['PathService'];
+
+export default atPanelBody;
diff --git a/awx/ui/client/lib/components/utility/divider.partial.html b/awx/ui/client/lib/components/utility/divider.partial.html
new file mode 100644
index 0000000000..514695d55c
--- /dev/null
+++ b/awx/ui/client/lib/components/utility/divider.partial.html
@@ -0,0 +1 @@
+
diff --git a/awx/ui/client/lib/models/Base.js b/awx/ui/client/lib/models/Base.js
new file mode 100644
index 0000000000..5e5c1d3551
--- /dev/null
+++ b/awx/ui/client/lib/models/Base.js
@@ -0,0 +1,162 @@
+let $http;
+let $q;
+
+function request (method, resource) {
+ if (Array.isArray(method) && Array.isArray(resource)) {
+ let promises = method.map((value, i) => this.http[value](resource[i]));
+
+ return $q.all(promises);
+ }
+
+ return this.http[method](resource);
+}
+
+function httpGet (resource) {
+ let req = {
+ method: 'GET',
+ url: this.path
+ };
+
+ if (typeof resource === 'object') {
+ this.model[this.method] = resource;
+
+ return $q.resolve();
+ } else if (resource) {
+ req.url = `${this.path}${resource}/`;
+ }
+
+ return $http(req)
+ .then(res => {
+ this.model.GET = res.data;
+
+ return res;
+ });
+}
+
+function httpPost (data) {
+ let req = {
+ method: 'POST',
+ url: this.path,
+ data
+ };
+
+ return $http(req).then(res => {
+ this.model.GET = res.data;
+
+ return res;
+ });
+}
+
+function httpPut (changes) {
+ let model = Object.assign(this.get(), changes);
+
+ let req = {
+ method: 'PUT',
+ url: `${this.path}${model.id}/`,
+ data: model
+ };
+
+ return $http(req).then(res => res);
+}
+
+function httpOptions (resource) {
+ let req = {
+ method: 'OPTIONS',
+ url: this.path
+ };
+
+ if (resource) {
+ req.url = `${this.path}${resource}/`;
+ }
+
+ return $http(req)
+ .then(res => {
+ this.model.OPTIONS = res.data;
+
+ return res;
+ });
+}
+
+function get (method, keys) {
+ let model;
+
+ if (keys) {
+ model = this.model[method.toUpperCase()];
+ } else {
+ model = this.model.GET;
+ keys = method;
+ }
+
+ if (!keys) {
+ return model;
+ }
+
+ keys = keys.split('.');
+
+ let value = model;
+
+ try {
+ keys.forEach(key => {
+ let bracketIndex = key.indexOf('[');
+ let hasArray = bracketIndex !== -1;
+
+ if (!hasArray) {
+ value = value[key];
+ return;
+ }
+
+ if (bracketIndex === 0) {
+ value = value[Number(key.substring(1, key.length - 1))];
+ return;
+ }
+
+ let prop = key.substring(0, bracketIndex);
+ let index = Number(key.substring(bracketIndex + 1, key.length - 1));
+
+ value = value[prop][index];
+ });
+ } catch (err) {
+ return undefined;
+ }
+
+ return value;
+}
+
+function normalizePath (resource) {
+ let version = '/api/v2/';
+
+ return `${version}${resource}/`;
+}
+
+function getById (id) {
+ let item = this.get('results').filter(result => result.id === id);
+
+ return item ? item[0] : undefined;
+}
+
+function BaseModel (path) {
+ this.model = {};
+ this.get = get;
+ this.normalizePath = normalizePath;
+ this.getById = getById;
+ this.request = request;
+ this.http = {
+ get: httpGet.bind(this),
+ options: httpOptions.bind(this),
+ post: httpPost.bind(this),
+ put: httpPut.bind(this)
+ };
+
+ this.path = this.normalizePath(path);
+};
+
+function BaseModelLoader (_$http_, _$q_) {
+ $http = _$http_;
+ $q = _$q_;
+
+ return BaseModel;
+}
+
+BaseModelLoader.$inject = ['$http', '$q'];
+
+export default BaseModelLoader;
diff --git a/awx/ui/client/lib/models/Credential.js b/awx/ui/client/lib/models/Credential.js
new file mode 100644
index 0000000000..451472acab
--- /dev/null
+++ b/awx/ui/client/lib/models/Credential.js
@@ -0,0 +1,59 @@
+const ENCRYPTED_VALUE = '$encrypted$';
+
+let BaseModel;
+
+function createFormSchema (method, config) {
+ let schema = Object.assign({}, this.get('options', `actions.${method.toUpperCase()}`));
+
+ if (config && config.omit) {
+ config.omit.forEach(key => {
+ delete schema[key];
+ });
+ }
+
+ for (let key in schema) {
+ schema[key].id = key;
+
+ if (method === 'put') {
+ schema[key]._value = this.get(key);
+ }
+ }
+
+ return schema;
+}
+
+function assignInputGroupValues (inputs) {
+ return inputs.map(input => {
+ let value = this.get(`inputs.${input.id}`);
+
+ input._value = value;
+ input._encrypted = value === ENCRYPTED_VALUE;
+
+ return input;
+ });
+}
+
+function clearTypeInputs () {
+ delete this.model.GET.inputs;
+}
+
+function CredentialModel (method, resource) {
+ BaseModel.call(this, 'credentials');
+
+ this.createFormSchema = createFormSchema.bind(this);
+ this.assignInputGroupValues = assignInputGroupValues.bind(this);
+ this.clearTypeInputs = clearTypeInputs.bind(this);
+
+ return this.request(method, resource)
+ .then(() => this);
+}
+
+function CredentialModelLoader (_BaseModel_ ) {
+ BaseModel = _BaseModel_;
+
+ return CredentialModel;
+}
+
+CredentialModelLoader.$inject = ['BaseModel'];
+
+export default CredentialModelLoader;
diff --git a/awx/ui/client/lib/models/CredentialType.js b/awx/ui/client/lib/models/CredentialType.js
new file mode 100644
index 0000000000..2679e191d7
--- /dev/null
+++ b/awx/ui/client/lib/models/CredentialType.js
@@ -0,0 +1,47 @@
+let BaseModel;
+
+function categorizeByKind () {
+ let group = {};
+
+ this.get('results').forEach(result => {
+ group[result.kind] = group[result.kind] || [];
+ group[result.kind].push(result);
+ });
+
+ return Object.keys(group).map(category => ({
+ data: group[category],
+ category
+ }));
+}
+
+function mergeInputProperties (type) {
+ return type.inputs.fields.map(field => {
+ if (!type.inputs.required || type.inputs.required.indexOf(field.id) === -1) {
+ field.required = false;
+ } else {
+ field.required = true;
+ }
+
+ return field;
+ });
+}
+
+function CredentialTypeModel (method, id) {
+ BaseModel.call(this, 'credential_types');
+
+ this.categorizeByKind = categorizeByKind.bind(this);
+ this.mergeInputProperties = mergeInputProperties.bind(this);
+
+ return this.request(method, id)
+ .then(() => this);
+}
+
+function CredentialTypeModelLoader (_BaseModel_) {
+ BaseModel = _BaseModel_;
+
+ return CredentialTypeModel;
+}
+
+CredentialTypeModelLoader.$inject = ['BaseModel'];
+
+export default CredentialTypeModelLoader;
diff --git a/awx/ui/client/lib/models/Me.js b/awx/ui/client/lib/models/Me.js
new file mode 100644
index 0000000000..2a36a5b2be
--- /dev/null
+++ b/awx/ui/client/lib/models/Me.js
@@ -0,0 +1,24 @@
+let BaseModel;
+
+function getSelf () {
+ return this.get('results[0]');
+}
+
+function MeModel (method) {
+ BaseModel.call(this, 'me');
+
+ this.getSelf = getSelf.bind(this);
+
+ return this.request(method)
+ .then(() => this);
+}
+
+function MeModelLoader (_BaseModel_) {
+ BaseModel = _BaseModel_;
+
+ return MeModel;
+}
+
+MeModelLoader.$inject = ['BaseModel'];
+
+export default MeModelLoader;
diff --git a/awx/ui/client/lib/models/Organization.js b/awx/ui/client/lib/models/Organization.js
new file mode 100644
index 0000000000..7dc7758f78
--- /dev/null
+++ b/awx/ui/client/lib/models/Organization.js
@@ -0,0 +1,18 @@
+let BaseModel;
+
+function OrganizationModel (method) {
+ BaseModel.call(this, 'organizations');
+
+ return this.request(method)
+ .then(() => this);
+}
+
+function OrganizationModelLoader (_BaseModel_) {
+ BaseModel = _BaseModel_;
+
+ return OrganizationModel;
+}
+
+OrganizationModelLoader.$inject = ['BaseModel'];
+
+export default OrganizationModelLoader;
diff --git a/awx/ui/client/lib/models/index.js b/awx/ui/client/lib/models/index.js
new file mode 100644
index 0000000000..9c48b5922b
--- /dev/null
+++ b/awx/ui/client/lib/models/index.js
@@ -0,0 +1,14 @@
+import Base from './Base';
+import Credential from './Credential';
+import CredentialType from './CredentialType';
+import Me from './Me';
+import Organization from './Organization';
+
+angular
+ .module('at.lib.models', [])
+ .service('BaseModel', Base)
+ .service('CredentialModel', Credential)
+ .service('CredentialTypeModel', CredentialType)
+ .service('MeModel', Me)
+ .service('OrganizationModel', Organization);
+
diff --git a/awx/ui/client/lib/services/event.service.js b/awx/ui/client/lib/services/event.service.js
new file mode 100644
index 0000000000..1d4e95e1e3
--- /dev/null
+++ b/awx/ui/client/lib/services/event.service.js
@@ -0,0 +1,37 @@
+function EventService () {
+ this.addListeners = list => {
+ let listeners = [];
+
+ list.forEach(args => listeners.push(this.addListener(...args)));
+
+ return listeners;
+ };
+
+ this.addListener = (el, name, fn) => {
+ let listener = {
+ fn,
+ name,
+ el
+ };
+
+ if (Array.isArray(name)) {
+ name.forEach(e => listener.el.addEventListener(e, listener.fn));
+ } else {
+ listener.el.addEventListener(listener.name, listener.fn);
+ }
+
+ return listener;
+ };
+
+ this.remove = listeners => {
+ listeners.forEach(listener => {
+ if (Array.isArray(listener.name)) {
+ listener.name.forEach(name => listener.el.removeEventListener(name, listener.fn));
+ } else {
+ listener.el.removeEventListener(listener.name, listener.fn);
+ }
+ });
+ };
+}
+
+export default EventService;
diff --git a/awx/ui/client/lib/services/index.js b/awx/ui/client/lib/services/index.js
new file mode 100644
index 0000000000..d6a256b0fc
--- /dev/null
+++ b/awx/ui/client/lib/services/index.js
@@ -0,0 +1,7 @@
+import EventService from './event.service';
+import PathService from './path.service';
+
+angular
+ .module('at.lib.services', [])
+ .service('EventService', EventService)
+ .service('PathService', PathService);
diff --git a/awx/ui/client/lib/services/path.service.js b/awx/ui/client/lib/services/path.service.js
new file mode 100644
index 0000000000..9d41c25cc2
--- /dev/null
+++ b/awx/ui/client/lib/services/path.service.js
@@ -0,0 +1,11 @@
+function PathService () {
+ this.getPartialPath = path => {
+ return `/static/partials/${path}.partial.html`;
+ };
+
+ this.getViewPath = path => {
+ return `/static/views/${path}.view.html`;
+ }
+}
+
+export default PathService;
diff --git a/awx/ui/client/lib/theme/_common.less b/awx/ui/client/lib/theme/_common.less
new file mode 100644
index 0000000000..bfd3fc7f48
--- /dev/null
+++ b/awx/ui/client/lib/theme/_common.less
@@ -0,0 +1,38 @@
+/**
+ * For styles that are used in more than one place throughout the application.
+ *
+ * 1. Buttons
+ *
+ */
+
+// 1. Buttons -------------------------------------------------------------------------------------
+
+.at-Button--green {
+ .at-mixin-Button();
+ .at-mixin-ButtonColor('at-green', 'at-white');
+
+ &[disabled] {
+ background: @at-gray-dark;
+ }
+}
+
+.at-Button--blue {
+ .at-mixin-Button();
+ .at-mixin-ButtonColor('at-blue', 'at-white');
+}
+
+.at-Button--red {
+ .at-mixin-Button();
+ .at-mixin-ButtonColor('at-red', 'at-white');
+}
+
+.at-ButtonHollow--white {
+ .at-mixin-Button();
+ .at-mixin-ButtonHollow('at-gray-dark-3x', 'at-gray-dark-2x');
+ border-color: @at-gray-dark;
+}
+
+.at-ButtonIcon {
+ padding: @at-space-2x @at-space-4x;
+ font-size: @at-font-size-3x;
+}
diff --git a/awx/ui/client/lib/theme/_mixins.less b/awx/ui/client/lib/theme/_mixins.less
new file mode 100644
index 0000000000..845a9ae030
--- /dev/null
+++ b/awx/ui/client/lib/theme/_mixins.less
@@ -0,0 +1,83 @@
+.at-mixin-Placeholder (@color) {
+ &:-moz-placeholder {
+ color: @color;
+ }
+ &:-ms-input-placeholder {
+ color: @color;
+ }
+ &::-webkit-input-placeholder {
+ color: @color;
+ }
+}
+
+.at-mixin-Heading (@size) {
+ color: @at-gray-dark-4x;
+ font-size: @size;
+ font-weight: @at-font-weight-2x;
+ line-height: @at-line-height-short;
+ text-transform: uppercase;
+ margin: 0;
+ padding: 0;
+}
+
+.at-mixin-Button () {
+ height: @at-input-height;
+ padding: @at-space-2x @at-space-4x;
+ font-size: @at-font-size;
+}
+
+.at-mixin-ButtonColor (@background, @color, @hover: '@{background}--hover') {
+ background-color: @@background;
+
+ &, &:hover, &:focus {
+ color: @@color;
+ }
+
+ &:hover, &:focus {
+ background-color: @@hover;
+ }
+
+ &[disabled] {
+ background-color: fade(@@background, 60%);
+ }
+}
+
+.at-mixin-ButtonHollow (@color, @accent) {
+ background-color: @at-white;
+ color: @@color;
+ border-color: @@color;
+
+ &:hover, &:active {
+ color: @@color;
+ background-color: @at-white--hover;
+ box-shadow: none;
+ }
+
+ &:focus {
+ color: @at-white;
+ background-color: @@accent;
+ border-color: @@accent;
+ cursor: default;
+ }
+
+ &[disabled] {
+ opacity: 0.65;
+ }
+}
+
+.at-mixin-ButtonIcon () {
+ line-height: @at-line-height-short;
+ color: @at-gray-dark-2x;
+
+ & > i {
+ cursor: pointer;
+ }
+
+ & > i:hover {
+ color: @at-gray-dark-3x;
+ }
+}
+
+.at-mixin-FontFixedWidth () {
+ font-family: Menlo, Monaco, Consolas, "Courier New", monospace;
+}
diff --git a/awx/ui/client/lib/theme/_temporary-overrides.less b/awx/ui/client/lib/theme/_temporary-overrides.less
new file mode 100644
index 0000000000..e7070fa42d
--- /dev/null
+++ b/awx/ui/client/lib/theme/_temporary-overrides.less
@@ -0,0 +1,5 @@
+// TODO (remove override on cleanup):
+
+.at-Panel-heading:hover {
+ cursor: default;
+}
diff --git a/awx/ui/client/lib/theme/_utility.less b/awx/ui/client/lib/theme/_utility.less
new file mode 100644
index 0000000000..1f47a481f3
--- /dev/null
+++ b/awx/ui/client/lib/theme/_utility.less
@@ -0,0 +1,18 @@
+.at-u-noSpace {
+ margin: 0;
+ padding: 0;
+}
+
+.at-u-flat {
+ padding-top: 0;
+ padding-bottom: 0;
+ margin-top: 0;
+ margin-bottom: 0;
+}
+
+.at-u-thin {
+ padding-left: 0;
+ padding-right: 0;
+ margin-left: 0;
+ margin-right: 0;
+}
diff --git a/awx/ui/client/lib/theme/_variables.less b/awx/ui/client/lib/theme/_variables.less
new file mode 100644
index 0000000000..f8ad4086e7
--- /dev/null
+++ b/awx/ui/client/lib/theme/_variables.less
@@ -0,0 +1,72 @@
+/**
+ * All variables used in the UI.
+ *
+ * 1. Colors
+ * 2. Typography
+ * 3. Layout
+ * 4. Input
+ * 5. Misc
+ */
+
+// 1. Colors --------------------------------------------------------------------------------------
+@at-gray-light-5x: #fcfcfc;
+@at-gray-light-4x: #fafafa;
+@at-gray-light-3x: #f6f6f6;
+@at-gray-light-2x: #f2f2f2;
+@at-gray-light: #ebebeb;
+@at-gray: #e1e1e1;
+@at-gray-dark: #d7d7d7;
+@at-gray-dark-2x: #b7b7b7;
+@at-gray-dark-3x: #848992;
+@at-gray-dark-4x: #707070;
+@at-gray-dark-5x: #161b1f;
+
+@at-white: #ffffff;
+@at-white--hover: #f2f2f2;
+
+@at-blue: #337ab7;
+@at-blue--hover: #286090;
+
+@at-green: #5cb85c;
+@at-green--hover: #449D44;
+
+@at-yellow: #f0ad4e;
+@at-yellow--hover: #ec971f;
+
+@at-red: #d9534f;
+@at-red--hover: #c9302c;
+
+@at-redAlert: #ff0000;
+@at-redAlert--hover: #d81f1f;
+
+// 2. Typography ----------------------------------------------------------------------------------
+@at-font-size: 12px;
+@at-font-size-2x: 13px;
+@at-font-size-3x: 14px;
+@at-font-size-4x: 16px;
+
+@at-font-weight: 400;
+@at-font-weight-2x: 700;
+@at-font-weight-3x: 900;
+
+@at-line-height-short: 0.9;
+@at-line-height-tall: 2;
+@at-line-height: 24px;
+
+// 3. Layout --------------------------------------------------------------------------------------
+@at-space: 3px;
+@at-space-2x: 4px;
+@at-space-3x: 5px;
+@at-space-4x: 10px;
+@at-space-5x: 15px;
+@at-space-6x: 20px;
+
+// 4. Input ---------------------------------------------------------------------------------------
+@at-input-button-width: 72px;
+@at-input-height: 30px;
+
+// 5. Misc ----------------------------------------------------------------------------------------
+@at-border-radius: 5px;
+@at-popover-width: 320px;
+@at-inset-width: 5px;
+
diff --git a/awx/ui/client/lib/theme/index.less b/awx/ui/client/lib/theme/index.less
new file mode 100644
index 0000000000..a7aeddaaca
--- /dev/null
+++ b/awx/ui/client/lib/theme/index.less
@@ -0,0 +1,15 @@
+// App-wide styles
+@import '_variables';
+@import '_mixins';
+@import '_utility';
+@import '_common';
+
+// Aggregated component and feature specific styles
+@import '../components/_index';
+@import '../../features/_index';
+
+/*
+ * Temporary overrides used only during the transition away from old style
+ * structure to new style structure. Overrides unwanted/uneeded rules.
+ */
+@import '_temporary-overrides';
diff --git a/awx/ui/client/src/access/permissions-list.controller.js b/awx/ui/client/src/access/permissions-list.controller.js
index 2bd40eb59f..ebdbc394d2 100644
--- a/awx/ui/client/src/access/permissions-list.controller.js
+++ b/awx/ui/client/src/access/permissions-list.controller.js
@@ -6,7 +6,6 @@
export default ['$scope', 'ListDefinition', 'Dataset', 'Wait', 'Rest', 'ProcessErrors', 'Prompt', '$state',
function($scope, list, Dataset, Wait, Rest, ProcessErrors, Prompt, $state) {
-
init();
function init() {
diff --git a/awx/ui/client/src/app.js b/awx/ui/client/src/app.js
index 02b13f0f40..e367c4d935 100644
--- a/awx/ui/client/src/app.js
+++ b/awx/ui/client/src/app.js
@@ -41,6 +41,7 @@ import portalMode from './portal-mode/main';
import systemTracking from './system-tracking/main';
import inventories from './inventories/main';
import inventoryScripts from './inventory-scripts/main';
+import credentials from './credentials/main';
import credentialTypes from './credential-types/main';
import organizations from './organizations/main';
import managementJobs from './management-jobs/main';
@@ -60,7 +61,6 @@ import login from './login/main';
import activityStream from './activity-stream/main';
import standardOut from './standard-out/main';
import Templates from './templates/main';
-import credentials from './credentials/main';
import jobs from './jobs/main';
import teams from './teams/main';
import users from './users/main';
@@ -72,6 +72,11 @@ import footer from './footer/main';
import scheduler from './scheduler/main';
import instanceGroups from './instance-groups/main';
+import '../lib/components';
+import '../lib/models';
+import '../lib/services';
+import '../features';
+
var tower = angular.module('Tower', [
// how to add CommonJS / AMD third-party dependencies:
// 1. npm install --save package-name
@@ -101,6 +106,7 @@ var tower = angular.module('Tower', [
systemTracking.name,
inventories.name,
inventoryScripts.name,
+ credentials.name,
credentialTypes.name,
organizations.name,
managementJobs.name,
@@ -118,7 +124,6 @@ var tower = angular.module('Tower', [
standardOut.name,
Templates.name,
portalMode.name,
- credentials.name,
jobs.name,
teams.name,
users.name,
@@ -131,6 +136,11 @@ var tower = angular.module('Tower', [
'PromptDialog',
'AWDirectives',
'features',
+
+ 'at.lib.components',
+ 'at.lib.models',
+ 'at.lib.services',
+ 'at.features',
])
.constant('AngularScheduler.partials', urlPrefix + 'lib/angular-scheduler/lib/')
diff --git a/awx/ui/client/src/credentials/add/credentials-add.controller.js b/awx/ui/client/src/credentials/add/credentials-add.controller.js
deleted file mode 100644
index bb5a708acf..0000000000
--- a/awx/ui/client/src/credentials/add/credentials-add.controller.js
+++ /dev/null
@@ -1,178 +0,0 @@
-/*************************************************
- * Copyright (c) 2016 Ansible, Inc.
- *
- * All Rights Reserved
- *************************************************/
-
-export default ['$scope', '$rootScope',
- '$log', '$stateParams', 'CredentialForm', 'GenerateForm', 'Rest',
- 'ProcessErrors', 'ClearScope', 'GetBasePath', 'GetChoices', 'Empty', 'KindChange', 'BecomeMethodChange',
- 'OwnerChange', 'CredentialFormSave', '$state', 'CreateSelect2', 'i18n',
- function($scope, $rootScope, $log,
- $stateParams, CredentialForm, GenerateForm, Rest, ProcessErrors,
- ClearScope, GetBasePath, GetChoices, Empty, KindChange, BecomeMethodChange,
- OwnerChange, CredentialFormSave, $state, CreateSelect2, i18n) {
-
- ClearScope();
-
- // Inject dynamic view
- var form = CredentialForm,
- defaultUrl = GetBasePath('credentials'),
- url;
-
- init();
-
- function init() {
- $scope.canEditOrg = true;
- // Load the list of options for Kind
- GetChoices({
- scope: $scope,
- url: defaultUrl,
- field: 'kind',
- variable: 'credential_kind_options'
- });
-
- GetChoices({
- scope: $scope,
- url: defaultUrl,
- field: 'become_method',
- variable: 'become_options'
- });
-
- CreateSelect2({
- element: '#credential_become_method',
- multiple: false
- });
-
- CreateSelect2({
- element: '#credential_kind',
- multiple: false
- });
-
- // apply form definition's default field values
- GenerateForm.applyDefaults(form, $scope);
-
- $scope.keyEntered = false;
- $scope.permissionsTooltip = i18n._('Please save before assigning permissions');
-
- // determine if the currently logged-in user may share this credential
- // previous commentary said: "$rootScope.current_user isn't available because a call to the config endpoint hasn't finished resolving yet"
- // I'm 99% sure this state's will never resolve block will be rejected if setup surrounding config endpoint hasn't completed
- if ($rootScope.current_user && $rootScope.current_user.is_superuser) {
- $scope.canShareCredential = true;
- } else {
- Rest.setUrl(GetBasePath('users') + `${$rootScope.current_user.id}/admin_of_organizations`);
- Rest.get()
- .success(function(data) {
- $scope.canShareCredential = (data.count) ? true : false;
- }).error(function(data, status) {
- ProcessErrors($scope, data, status, null, { hdr: 'Error!', msg: 'Failed to find if users is admin of org' + status });
- });
- }
- }
-
- if (!Empty($stateParams.user_id)) {
- // Get the username based on incoming route
- $scope.owner = 'user';
- $scope.user = $stateParams.user_id;
- OwnerChange({ scope: $scope });
- url = GetBasePath('users') + $stateParams.user_id + '/';
- Rest.setUrl(url);
- Rest.get()
- .success(function(data) {
- $scope.user_username = data.username;
- })
- .error(function(data, status) {
- ProcessErrors($scope, data, status, null, { hdr: 'Error!', msg: 'Failed to retrieve user. GET status: ' + status });
- });
- } else if (!Empty($stateParams.team_id)) {
- // Get the username based on incoming route
- $scope.owner = 'team';
- $scope.team = $stateParams.team_id;
- OwnerChange({ scope: $scope });
- url = GetBasePath('teams') + $stateParams.team_id + '/';
- Rest.setUrl(url);
- Rest.get()
- .success(function(data) {
- $scope.team_name = data.name;
- })
- .error(function(data, status) {
- ProcessErrors($scope, data, status, null, { hdr: 'Error!', msg: 'Failed to retrieve team. GET status: ' + status });
- });
- } else {
- // default type of owner to a user
- $scope.owner = 'user';
- OwnerChange({ scope: $scope });
- }
-
- $scope.$watch("ssh_key_data", function(val) {
- if (val === "" || val === null || val === undefined) {
- $scope.keyEntered = false;
- $scope.ssh_key_unlock_ask = false;
- $scope.ssh_key_unlock = "";
- } else {
- $scope.keyEntered = true;
- }
- });
-
- // Handle Kind change
- $scope.kindChange = function() {
- KindChange({ scope: $scope, form: form, reset: true });
- };
-
- $scope.becomeMethodChange = function() {
- BecomeMethodChange({ scope: $scope });
- };
-
- // Save
- $scope.formSave = function() {
- if ($scope[form.name + '_form'].$valid) {
- CredentialFormSave({ scope: $scope, mode: 'add' });
- }
- };
-
- $scope.formCancel = function() {
- $state.go('credentials');
- };
-
- // Password change
- $scope.clearPWConfirm = function(fld) {
- // If password value changes, make sure password_confirm must be re-entered
- $scope[fld] = '';
- $scope[form.name + '_form'][fld].$setValidity('awpassmatch', false);
- };
-
- // Respond to 'Ask at runtime?' checkbox
- $scope.ask = function(fld, associated) {
- if ($scope[fld + '_ask']) {
- $scope[fld] = 'ASK';
- $("#" + form.name + "_" + fld + "_input").attr("type", "text");
- $("#" + form.name + "_" + fld + "_show_input_button").html("Hide");
- if (associated !== "undefined") {
- $("#" + form.name + "_" + fld + "_input").attr("type", "password");
- $("#" + form.name + "_" + fld + "_show_input_button").html("Show");
- $scope[associated] = '';
- $scope[form.name + '_form'][associated].$setValidity('awpassmatch', true);
- }
- } else {
- $scope[fld] = '';
- $("#" + form.name + "_" + fld + "_input").attr("type", "password");
- $("#" + form.name + "_" + fld + "_show_input_button").html("Show");
- if (associated !== "undefined") {
- $("#" + form.name + "_" + fld + "_input").attr("type", "text");
- $("#" + form.name + "_" + fld + "_show_input_button").html("Hide");
- $scope[associated] = '';
- $scope[form.name + '_form'][associated].$setValidity('awpassmatch', true);
- }
- }
- };
-
- // Click clear button
- $scope.clear = function(fld, associated) {
- $scope[fld] = '';
- $scope[associated] = '';
- $scope[form.name + '_form'][associated].$setValidity('awpassmatch', true);
- $scope[form.name + '_form'].$setDirty();
- };
- }
-];
diff --git a/awx/ui/client/src/credentials/edit/credentials-edit.controller.js b/awx/ui/client/src/credentials/edit/credentials-edit.controller.js
deleted file mode 100644
index 97e0fcc6f2..0000000000
--- a/awx/ui/client/src/credentials/edit/credentials-edit.controller.js
+++ /dev/null
@@ -1,344 +0,0 @@
-/*************************************************
- * Copyright (c) 2016 Ansible, Inc.
- *
- * All Rights Reserved
- *************************************************/
-
-export default ['$scope', '$rootScope', '$location',
- '$stateParams', 'CredentialForm', 'Rest',
- 'ProcessErrors', 'ClearScope', 'Prompt', 'GetBasePath', 'GetChoices',
- 'KindChange', 'BecomeMethodChange', 'Empty', 'OwnerChange',
- 'CredentialFormSave', 'Wait', '$state', 'CreateSelect2', 'Authorization', 'i18n',
- function($scope, $rootScope, $location,
- $stateParams, CredentialForm, Rest, ProcessErrors, ClearScope, Prompt,
- GetBasePath, GetChoices, KindChange, BecomeMethodChange, Empty, OwnerChange, CredentialFormSave, Wait,
- $state, CreateSelect2, Authorization, i18n) {
-
- ClearScope();
-
- var defaultUrl = GetBasePath('credentials'),
- form = CredentialForm,
- base = $location.path().replace(/^\//, '').split('/')[0],
- master = {},
- id = $stateParams.credential_id;
-
- init();
-
- function init() {
- $scope.id = id;
- $scope.$watch('credential_obj.summary_fields.user_capabilities.edit', function(val) {
- if (val === false) {
- $scope.canAdd = false;
- }
- });
-
- $scope.canShareCredential = false;
- Wait('start');
- if (!$rootScope.current_user) {
- Authorization.restoreUserInfo();
- }
- GetChoices({
- scope: $scope,
- url: defaultUrl,
- field: 'kind',
- variable: 'credential_kind_options',
- callback: 'choicesReadyCredential'
- });
-
- GetChoices({
- scope: $scope,
- url: defaultUrl,
- field: 'become_method',
- variable: 'become_options'
- });
-
- if ($rootScope.current_user && $rootScope.current_user.is_superuser) {
- $scope.canShareCredential = true;
- } else {
- Rest.setUrl(GetBasePath('users') + `${$rootScope.current_user.id}/admin_of_organizations`);
- Rest.get()
- .success(function(data) {
- $scope.canShareCredential = (data.count) ? true : false;
- Wait('stop');
- }).error(function(data, status) {
- ProcessErrors($scope, data, status, null, { hdr: 'Error!', msg: 'Failed to find if users is admin of org' + status });
- });
- }
-
- $scope.$watch('organization', function(val) {
- if (val === undefined) {
- $scope.permissionsTooltip = i18n._('Credentials are only shared within an organization. Assign credentials to an organization to delegate credential permissions. The organization cannot be edited after credentials are assigned.');
- } else {
- $scope.permissionsTooltip = '';
- }
- });
-
- setAskCheckboxes();
- OwnerChange({ scope: $scope });
- $scope.$watch("ssh_key_data", function(val) {
- if (val === "" || val === null || val === undefined) {
- $scope.keyEntered = false;
- $scope.ssh_key_unlock_ask = false;
- $scope.ssh_key_unlock = "";
- } else {
- $scope.keyEntered = true;
- }
- });
- }
-
- function setAskCheckboxes() {
- var fld, i;
- for (fld in form.fields) {
- if (form.fields[fld].type === 'sensitive' && $scope[fld] === 'ASK') {
- // turn on 'ask' checkbox for password fields with value of 'ASK'
- $("#" + form.name + "_" + fld + "_input").attr("type", "text");
- $("#" + form.name + "_" + fld + "_show_input_button").html("Hide");
- $("#" + fld + "-clear-btn").attr("disabled", "disabled");
- $scope[fld + '_ask'] = true;
- } else {
- $scope[fld + '_ask'] = false;
- $("#" + fld + "-clear-btn").removeAttr("disabled");
- }
- master[fld + '_ask'] = $scope[fld + '_ask'];
- }
-
- // Set kind field to the correct option
- for (i = 0; i < $scope.credential_kind_options.length; i++) {
- if ($scope.kind === $scope.credential_kind_options[i].value) {
- $scope.kind = $scope.credential_kind_options[i];
- break;
- }
- }
- }
- if ($scope.removeChoicesReady) {
- $scope.removeChoicesReady();
- }
- $scope.removeChoicesReady = $scope.$on('choicesReadyCredential', function() {
- // Retrieve detail record and prepopulate the form
- Rest.setUrl(defaultUrl + ':id/');
- Rest.get({ params: { id: id } })
- .success(function(data) {
- if (data && data.summary_fields &&
- data.summary_fields.organization &&
- data.summary_fields.organization.id) {
- $scope.needsRoleList = true;
- } else {
- $scope.needsRoleList = false;
- }
-
- $scope.credential_name = data.name;
-
- var i, fld;
-
-
- for (fld in form.fields) {
- if (data[fld] !== null && data[fld] !== undefined) {
- $scope[fld] = data[fld];
- master[fld] = $scope[fld];
- }
- if (form.fields[fld].type === 'lookup' && data.summary_fields[form.fields[fld].sourceModel]) {
- $scope[form.fields[fld].sourceModel + '_' + form.fields[fld].sourceField] =
- data.summary_fields[form.fields[fld].sourceModel][form.fields[fld].sourceField];
- master[form.fields[fld].sourceModel + '_' + form.fields[fld].sourceField] =
- $scope[form.fields[fld].sourceModel + '_' + form.fields[fld].sourceField];
- }
- }
-
- if (!Empty($scope.user)) {
- $scope.owner = 'user';
- } else {
- $scope.owner = 'team';
- }
- master.owner = $scope.owner;
-
- for (i = 0; i < $scope.become_options.length; i++) {
- if ($scope.become_options[i].value === data.become_method) {
- $scope.become_method = $scope.become_options[i];
- break;
- }
- }
-
- if ($scope.become_method && $scope.become_method.value === "") {
- $scope.become_method = null;
- }
- master.become_method = $scope.become_method;
-
- $scope.$watch('become_method', function(val) {
- if (val !== null) {
- if (val.value === "") {
- $scope.become_username = "";
- $scope.become_password = "";
- }
- }
- });
-
- for (i = 0; i < $scope.credential_kind_options.length; i++) {
- if ($scope.credential_kind_options[i].value === data.kind) {
- $scope.kind = $scope.credential_kind_options[i];
- break;
- }
- }
-
- KindChange({
- scope: $scope,
- form: form,
- reset: false
- });
-
- master.kind = $scope.kind;
-
- CreateSelect2({
- element: '#credential_become_method',
- multiple: false
- });
-
- CreateSelect2({
- element: '#credential_kind',
- multiple: false
- });
-
- switch (data.kind) {
- case 'aws':
- $scope.access_key = data.username;
- $scope.secret_key = data.password;
- master.access_key = $scope.access_key;
- master.secret_key = $scope.secret_key;
- break;
- case 'ssh':
- $scope.ssh_password = data.password;
- master.ssh_password = $scope.ssh_password;
- break;
- case 'rax':
- $scope.api_key = data.password;
- master.api_key = $scope.api_key;
- break;
- case 'gce':
- $scope.email_address = data.username;
- $scope.project = data.project;
- break;
- case 'azure':
- $scope.subscription = data.username;
- break;
- }
- $scope.credential_obj = data;
-
- $scope.$emit('credentialLoaded');
- Wait('stop');
- })
- .error(function(data, status) {
- ProcessErrors($scope, data, status, form, {
- hdr: 'Error!',
- msg: 'Failed to retrieve Credential: ' + $stateParams.id + '. GET status: ' + status
- });
- });
- });
-
- // Save changes to the parent
- $scope.formSave = function() {
- if ($scope[form.name + '_form'].$valid) {
- CredentialFormSave({ scope: $scope, mode: 'edit' });
- }
- };
-
- // Handle Owner change
- $scope.ownerChange = function() {
- OwnerChange({ scope: $scope });
- };
-
- // Handle Kind change
- $scope.kindChange = function() {
- KindChange({ scope: $scope, form: form, reset: true });
- };
-
- $scope.becomeMethodChange = function() {
- BecomeMethodChange({ scope: $scope });
- };
-
- $scope.formCancel = function() {
- $state.transitionTo('credentials');
- };
-
- // Related set: Add button
- $scope.add = function(set) {
- $rootScope.flashMessage = null;
- $location.path('/' + base + '/' + $stateParams.id + '/' + set + '/add');
- };
-
- // Related set: Edit button
- $scope.edit = function(set, id) {
- $rootScope.flashMessage = null;
- $location.path('/' + base + '/' + $stateParams.id + '/' + set + '/' + id);
- };
-
- // Related set: Delete button
- $scope['delete'] = function(set, itm_id, name, title) {
- $rootScope.flashMessage = null;
-
- var action = function() {
- var url = defaultUrl + id + '/' + set + '/';
- Rest.setUrl(url);
- Rest.post({
- id: itm_id,
- disassociate: 1
- })
- .success(function() {
- $('#prompt-modal').modal('hide');
- })
- .error(function(data, status) {
- $('#prompt-modal').modal('hide');
- ProcessErrors($scope, data, status, null, {
- hdr: 'Error!',
- msg: 'Call to ' + url + ' failed. POST returned status: ' + status
- });
- });
- };
-
- Prompt({
- hdr: i18n._('Delete'),
- body: '' + i18n.sprintf(i18n._('Are you sure you want to remove the %s below from %s?'), title, $scope.name) + '
' + name + '
',
- action: action,
- actionText: i18n._('DELETE')
- });
-
- };
-
- // Password change
- $scope.clearPWConfirm = function(fld) {
- // If password value changes, make sure password_confirm must be re-entered
- $scope[fld] = '';
- $scope[form.name + '_form'][fld].$setValidity('awpassmatch', false);
- };
-
- // Respond to 'Ask at runtime?' checkbox
- $scope.ask = function(fld, associated) {
- if ($scope[fld + '_ask']) {
- $scope[fld] = 'ASK';
- $("#" + form.name + "_" + fld + "_input").attr("type", "text");
- $("#" + form.name + "_" + fld + "_show_input_button").html("Hide");
- if (associated !== "undefined") {
- $("#" + form.name + "_" + fld + "_input").attr("type", "password");
- $("#" + form.name + "_" + fld + "_show_input_button").html("Show");
- $scope[associated] = '';
- $scope[form.name + '_form'][associated].$setValidity('awpassmatch', true);
- }
- } else {
- $scope[fld] = '';
- $("#" + form.name + "_" + fld + "_input").attr("type", "password");
- $("#" + form.name + "_" + fld + "_show_input_button").html("Show");
- if (associated !== "undefined") {
- $("#" + form.name + "_" + fld + "_input").attr("type", "text");
- $("#" + form.name + "_" + fld + "_show_input_button").html("Hide");
- $scope[associated] = '';
- $scope[form.name + '_form'][associated].$setValidity('awpassmatch', true);
- }
- }
- };
-
- $scope.clear = function(fld, associated) {
- $scope[fld] = '';
- $scope[associated] = '';
- $scope[form.name + '_form'][associated].$setValidity('awpassmatch', true);
- $scope[form.name + '_form'].$setDirty();
- };
- }
-];
diff --git a/awx/ui/client/src/credentials/main.js b/awx/ui/client/src/credentials/main.js
index 0dfe1d8d5d..094e7ce568 100644
--- a/awx/ui/client/src/credentials/main.js
+++ b/awx/ui/client/src/credentials/main.js
@@ -6,15 +6,12 @@
import ownerList from './ownerList.directive';
import CredentialsList from './list/credentials-list.controller';
-import CredentialsAdd from './add/credentials-add.controller';
-import CredentialsEdit from './edit/credentials-edit.controller';
import BecomeMethodChange from './factories/become-method-change.factory';
import CredentialFormSave from './factories/credential-form-save.factory';
import KindChange from './factories/kind-change.factory';
import OwnerChange from './factories/owner-change.factory';
import CredentialList from './credentials.list';
import CredentialForm from './credentials.form';
-import { N_ } from '../i18n';
export default
angular.module('credentials', [])
@@ -24,38 +21,5 @@ export default
.factory('KindChange', KindChange)
.factory('OwnerChange', OwnerChange)
.controller('CredentialsList', CredentialsList)
- .controller('CredentialsAdd', CredentialsAdd)
- .controller('CredentialsEdit', CredentialsEdit)
.factory('CredentialList', CredentialList)
- .factory('CredentialForm', CredentialForm)
- .config(['$stateProvider', 'stateDefinitionsProvider',
- function($stateProvider, stateDefinitionsProvider) {
- let stateDefinitions = stateDefinitionsProvider.$get();
-
- // lazily generate a tree of substates which will replace this node in ui-router's stateRegistry
- // see: stateDefinition.factory for usage documentation
- $stateProvider.state({
- name: 'credentials',
- url: '/credentials',
- lazyLoad: () => stateDefinitions.generateTree({
- parent: 'credentials',
- modes: ['add', 'edit'],
- list: 'CredentialList',
- form: 'CredentialForm',
- controllers: {
- list: CredentialsList,
- add: CredentialsAdd,
- edit: CredentialsEdit
- },
- data: {
- activityStream: true,
- activityStreamTarget: 'credential'
- },
- ncyBreadcrumb: {
- parent: 'setup',
- label: N_('CREDENTIALS')
- }
- })
- });
- }
- ]);
+ .factory('CredentialForm', CredentialForm);
diff --git a/awx/ui/client/test/index.js b/awx/ui/client/test/index.js
new file mode 100644
index 0000000000..f54a6bef6a
--- /dev/null
+++ b/awx/ui/client/test/index.js
@@ -0,0 +1,5 @@
+import 'angular';
+import 'angular-mocks';
+
+import '../components';
+import './panel.spec';
diff --git a/awx/ui/client/test/karma.conf.js b/awx/ui/client/test/karma.conf.js
new file mode 100644
index 0000000000..591f7984b1
--- /dev/null
+++ b/awx/ui/client/test/karma.conf.js
@@ -0,0 +1,47 @@
+let path = require('path');
+
+module.exports = config => {
+ config.set({
+ basePath: '',
+ singleRun: true,
+ autoWatch: false,
+ colors: true,
+ frameworks: ['jasmine'],
+ browsers: ['PhantomJS'],
+ reporters: ['progress'],
+ files: [
+ './index.js',
+ '../components/**/*.html'
+ ],
+ plugins: [
+ 'karma-webpack',
+ 'karma-jasmine',
+ 'karma-phantomjs-launcher',
+ 'karma-ng-html2js-preprocessor'
+ ],
+ preprocessors: {
+ '../components/**/*.html': 'ng-html2js',
+ '../components/index.js': 'webpack',
+ './index.js': 'webpack'
+ },
+ ngHtml2JsPreprocessor: {
+ moduleName: 'at.test.templates',
+ stripPrefix: path.resolve(__dirname, '..'),
+ prependPrefix: 'static/partials'
+ },
+ webpack: {
+ module: {
+ loaders: [
+ {
+ test: /\.js$/,
+ loader: 'babel',
+ exclude: /node_modules/
+ }
+ ]
+ }
+ },
+ webpackMiddleware: {
+ noInfo: 'errors-only'
+ }
+ });
+};
diff --git a/awx/ui/client/test/panel.spec.js b/awx/ui/client/test/panel.spec.js
new file mode 100644
index 0000000000..eeafa0d5a5
--- /dev/null
+++ b/awx/ui/client/test/panel.spec.js
@@ -0,0 +1,22 @@
+describe('Components | panel', () => {
+
+ let $compile;
+ let $rootScope;
+
+ beforeEach(done => {
+ angular.mock.module('at.components')
+ angular.mock.module('at.test.templates');
+
+ inject((_$compile_, _$rootScope_) => {
+ $compile = _$compile_;
+ $rootScope = _$rootScope_;
+ done();
+ });
+ });
+
+ it('should load the navigation partial', function() {
+ var element = $compile('')($rootScope);
+ $rootScope.$digest();
+
+ expect(element.html()).toContain('at-Panel');
+ }); });
diff --git a/awx/ui/grunt-tasks/browserSync.js b/awx/ui/grunt-tasks/browserSync.js
index efb32e31f9..9dc0272d48 100644
--- a/awx/ui/grunt-tasks/browserSync.js
+++ b/awx/ui/grunt-tasks/browserSync.js
@@ -18,6 +18,7 @@ module.exports = {
},
keepalive: false,
watchTask: true,
+ reloadDebounce: 1000,
// The browser-sync-client lib will write your current scroll position to window.name
// https://github.com/BrowserSync/browser-sync-client/blob/a2718faa91e11553feca7a3962313bf1ec6ba3e5/dist/index.js#L500
// This strategy is enabled in the core browser-sync lib, and not externally documented as an option. Yay!
diff --git a/awx/ui/grunt-tasks/concurrent.js b/awx/ui/grunt-tasks/concurrent.js
index 45085340bf..3cafeba559 100644
--- a/awx/ui/grunt-tasks/concurrent.js
+++ b/awx/ui/grunt-tasks/concurrent.js
@@ -1,16 +1,16 @@
module.exports = {
dev: {
- tasks: ['copy:vendor', 'copy:assets', 'copy:partials', 'copy:languages', 'copy:config', 'less:dev'],
+ tasks: ['copy:vendor', 'copy:assets', 'copy:partials', 'copy:views', 'copy:languages', 'copy:config', 'less:dev'],
},
// This concurrent target is intended for development ui builds that do not require raising browser-sync or filesystem polling
devNoSync: {
- tasks: ['copy:vendor', 'copy:assets', 'copy:partials', 'copy:languages', 'copy:config', 'less:dev', 'webpack:dev'],
+ tasks: ['copy:vendor', 'copy:assets', 'copy:partials', 'copy:views', 'copy:languages', 'copy:config', 'less:dev', 'webpack:dev'],
},
prod: {
- tasks: ['newer:copy:vendor', 'newer:copy:assets', 'newer:copy:partials', 'newer:copy:languages', 'newer:copy:config', 'newer:less:prod']
+ tasks: ['newer:copy:vendor', 'newer:copy:assets', 'newer:copy:partials', 'newer:copy:views', 'newer:copy:languages', 'newer:copy:config', 'newer:less:prod']
},
watch: {
- tasks: ['watch:css', 'watch:partials', 'watch:assets', ['webpack:dev', 'watch:config']],
+ tasks: ['watch:css', 'watch:partials', 'watch:views', 'watch:assets', ['webpack:dev', 'watch:config']],
options: {
logConcurrentOutput: true
}
diff --git a/awx/ui/grunt-tasks/copy.js b/awx/ui/grunt-tasks/copy.js
index 5e32be24b3..327787ef0f 100644
--- a/awx/ui/grunt-tasks/copy.js
+++ b/awx/ui/grunt-tasks/copy.js
@@ -30,6 +30,14 @@ module.exports = {
dest: 'static/lib/'
}]
},
+ views: {
+ files: [{
+ cwd: 'client/features',
+ expand: true,
+ src: ['**/*.view.html'],
+ dest: 'static/views/'
+ }]
+ },
partials: {
files: [{
cwd: 'client/src',
@@ -41,6 +49,11 @@ module.exports = {
expand: true,
src: ['*.html'],
dest: 'static/partials/'
+ }, {
+ cwd: 'client/lib/components',
+ expand: true,
+ src: ['**/*.partial.html'],
+ dest: 'static/partials/components/'
}]
},
languages: {
diff --git a/awx/ui/grunt-tasks/less.js b/awx/ui/grunt-tasks/less.js
index d7c6d98245..3995ae3485 100644
--- a/awx/ui/grunt-tasks/less.js
+++ b/awx/ui/grunt-tasks/less.js
@@ -10,18 +10,19 @@ module.exports = {
src: [
'client/legacy-styles/*.less',
'client/src/**/*.less',
+ 'client/lib/theme/index.less'
]
}],
options: {
sourceMap: true
}
},
-
prod: {
files: {
'static/tower.min.css': [
'client/legacy-styles/*.less',
'client/src/**/*.less',
+ 'client/lib/theme/index.less'
]
},
options: {
diff --git a/awx/ui/grunt-tasks/watch.js b/awx/ui/grunt-tasks/watch.js
index ba0b852038..a9cf2a2fcc 100644
--- a/awx/ui/grunt-tasks/watch.js
+++ b/awx/ui/grunt-tasks/watch.js
@@ -1,12 +1,19 @@
module.exports = {
css: {
files: 'client/**/*.less',
- tasks: ['newer:less:dev']
+ tasks: ['less:dev']
},
partials: {
- files: 'client/src/**/*.html',
+ files: [
+ 'client/lib/components/**/*.partial.html',
+ 'client/src/**/*.partial.html'
+ ],
tasks: ['newer:copy:partials']
},
+ views: {
+ files: 'client/features/**/*.view.html',
+ tasks: ['newer:copy:views']
+ },
assets: {
files: 'client/assets',
tasks: ['newer:copy:assets']
diff --git a/awx/ui/karma.conf.js b/awx/ui/karma.conf.js
index 1a22f476b2..3ae41b7982 100644
--- a/awx/ui/karma.conf.js
+++ b/awx/ui/karma.conf.js
@@ -61,7 +61,11 @@ module.exports = function(config) {
}, {
test: /\.js$/,
loader: 'babel-loader',
- include: [path.resolve() + '/client/src/'],
+ include: [
+ path.resolve() + '/client/src/',
+ path.resolve() + '/client/lib/',
+ path.resolve() + '/client/features/'
+ ],
exclude: '/(node_modules)/',
query: {
presets: ['es2015'],
diff --git a/awx/ui/package.json b/awx/ui/package.json
index 4474b90966..97785cbc90 100644
--- a/awx/ui/package.json
+++ b/awx/ui/package.json
@@ -24,7 +24,10 @@
"test": "karma start karma.conf.js",
"jshint": "grunt clean:jshint jshint:source --no-color",
"test:ci": "npm run test -- --single-run --reporter junit,dots --browsers=PhantomJS",
- "lint": "./node_modules/.bin/eslint -c .eslintrc.js ."
+ "lint": "./node_modules/.bin/eslint -c .eslintrc.js .",
+ "component-test": "./node_modules/.bin/karma start client/test/karma.conf.js",
+ "lint-dev": "./node_modules/.bin/nodemon --exec \"./node_modules/.bin/eslint -c .eslintrc.js .\" --watch \"client/components/**/*.js\"",
+ "component-dev": "./node_modules/.bin/karma start client/test/karma.conf.js --auto-watch --no-single-run"
},
"optionalDependencies": {
"browser-sync": "^2.14.0",
@@ -67,6 +70,7 @@
"karma-html2js-preprocessor": "^1.0.0",
"karma-jasmine": "^1.1.0",
"karma-junit-reporter": "^1.2.0",
+ "karma-ng-html2js-preprocessor": "^1.0.0",
"karma-phantomjs-launcher": "^1.0.2",
"karma-sauce-launcher": "^1.0.0",
"karma-sourcemap-loader": "^0.3.7",
diff --git a/echo b/echo
new file mode 100644
index 0000000000..e69de29bb2