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}]"> + ` + ); + }; + + 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