diff --git a/awx/ui/client/features/applications/add-applications.controller.js b/awx/ui/client/features/applications/add-applications.controller.js
new file mode 100644
index 0000000000..173460157e
--- /dev/null
+++ b/awx/ui/client/features/applications/add-applications.controller.js
@@ -0,0 +1,78 @@
+function AddApplicationsController (models, $state, strings) {
+ const vm = this || {};
+
+ const { application, me, organization } = models;
+ const omit = [
+ 'authorization_grant_type',
+ 'client_id',
+ 'client_secret',
+ 'client_type',
+ 'created',
+ 'modified',
+ 'related',
+ 'skip_authorization',
+ 'summary_fields',
+ 'type',
+ 'url',
+ 'user'
+ ];
+
+ vm.mode = 'add';
+ vm.strings = strings;
+ vm.panelTitle = strings.get('add.PANEL_TITLE');
+
+ vm.tab = {
+ details: { _active: true },
+ users: { _disabled: true }
+ };
+
+ vm.form = application.createFormSchema('post', { omit });
+
+ vm.form.organization = {
+ type: 'field',
+ label: 'Organization',
+ id: 'organization'
+ };
+ vm.form.description = {
+ type: 'String',
+ label: 'Description',
+ id: 'description'
+ };
+
+ vm.form.disabled = !application.isCreatable();
+
+ vm.form.organization._resource = 'organization';
+ vm.form.organization._route = 'applications.add.organization';
+ vm.form.organization._model = organization;
+ vm.form.organization._placeholder = strings.get('SELECT AN ORGANIZATION');
+
+ vm.form.name.required = true;
+ vm.form.organization.required = true;
+ vm.form.redirect_uris.required = true;
+
+ delete vm.form.name.help_text;
+
+ vm.form.save = data => {
+ const hiddenData = {
+ authorization_grant_type: 'implicit',
+ user: me.get('id'),
+ client_type: 'public'
+ };
+
+ const payload = _.merge(data, hiddenData);
+
+ return application.request('post', { data: payload });
+ };
+
+ vm.form.onSaveSuccess = res => {
+ $state.go('applications.edit', { application_id: res.data.id }, { reload: true });
+ };
+}
+
+AddApplicationsController.$inject = [
+ 'resolvedModels',
+ '$state',
+ 'ApplicationsStrings'
+];
+
+export default AddApplicationsController;
diff --git a/awx/ui/client/features/applications/add-edit-applications.view.html b/awx/ui/client/features/applications/add-edit-applications.view.html
new file mode 100644
index 0000000000..a8d8d68e6c
--- /dev/null
+++ b/awx/ui/client/features/applications/add-edit-applications.view.html
@@ -0,0 +1,29 @@
+
+
+ {{ vm.panelTitle }}
+
+
+
+ {{:: vm.strings.get('tab.DETAILS') }}
+ {{:: vm.strings.get('tab.USERS') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/awx/ui/client/features/applications/applications.strings.js b/awx/ui/client/features/applications/applications.strings.js
new file mode 100644
index 0000000000..18158c4d4e
--- /dev/null
+++ b/awx/ui/client/features/applications/applications.strings.js
@@ -0,0 +1,32 @@
+function ApplicationsStrings (BaseString) {
+ BaseString.call(this, 'applications');
+
+ const { t } = this;
+ const ns = this.applications;
+
+ ns.state = {
+ LIST_BREADCRUMB_LABEL: t.s('APPLICATIONS'),
+ ADD_BREADCRUMB_LABEL: t.s('CREATE APPLICATION'),
+ EDIT_BREADCRUMB_LABEL: t.s('EDIT APPLICATION'),
+ USER_LIST_BREADCRUMB_LABEL: t.s('TOKENS')
+ };
+
+ ns.tab = {
+ DETAILS: t.s('Details'),
+ USERS: t.s('Tokens')
+ };
+
+ ns.add = {
+ PANEL_TITLE: t.s('NEW APPLICATION')
+ };
+
+ ns.list = {
+ ROW_ITEM_LABEL_EXPIRED: t.s('EXPIRATION'),
+ ROW_ITEM_LABEL_ORGANIZATION: t.s('ORG'),
+ ROW_ITEM_LABEL_MODIFIED: t.s('LAST MODIFIED')
+ };
+}
+
+ApplicationsStrings.$inject = ['BaseStringService'];
+
+export default ApplicationsStrings;
diff --git a/awx/ui/client/features/applications/edit-applications.controller.js b/awx/ui/client/features/applications/edit-applications.controller.js
new file mode 100644
index 0000000000..6279b642ee
--- /dev/null
+++ b/awx/ui/client/features/applications/edit-applications.controller.js
@@ -0,0 +1,115 @@
+function EditApplicationsController (models, $state, strings, $scope) {
+ const vm = this || {};
+
+ const { me, application, organization } = models;
+
+ const omit = [
+ 'authorization_grant_type',
+ 'client_id',
+ 'client_secret',
+ 'client_type',
+ 'created',
+ 'modified',
+ 'related',
+ 'skip_authorization',
+ 'summary_fields',
+ 'type',
+ 'url',
+ 'user'
+ ];
+ const isEditable = application.isEditable();
+
+ vm.mode = 'edit';
+ vm.strings = strings;
+ vm.panelTitle = application.get('name');
+
+ vm.tab = {
+ details: {
+ _active: true,
+ _go: 'applications.edit',
+ _params: { application_id: application.get('id') }
+ },
+ users: {
+ _go: 'applications.edit.users',
+ _params: { application_id: application.get('id') }
+ }
+ };
+
+ $scope.$watch('$state.current.name', (value) => {
+ if (/applications.edit.users/.test(value)) {
+ vm.tab.details._active = false;
+ vm.tab.users._active = true;
+ } else {
+ vm.tab.details._active = true;
+ vm.tab.users._active = false;
+ }
+ });
+
+ if (isEditable) {
+ vm.form = application.createFormSchema('put', { omit });
+ } else {
+ vm.form = application.createFormSchema({ omit });
+ vm.form.disabled = !isEditable;
+ }
+
+ vm.form.disabled = !isEditable;
+
+ const isOrgAdmin = _.some(me.get('related.admin_of_organizations.results'), (org) => org.id === organization.get('id'));
+ const isSuperuser = me.get('is_superuser');
+ const isCurrentAuthor = Boolean(application.get('summary_fields.created_by.id') === me.get('id'));
+
+ vm.form.organization = {
+ type: 'field',
+ label: 'Organization',
+ id: 'organization'
+ };
+ vm.form.description = {
+ type: 'String',
+ label: 'Description',
+ id: 'description'
+ };
+
+ vm.form.organization._resource = 'organization';
+ vm.form.organization._route = 'applications.edit.organization';
+ vm.form.organization._model = organization;
+ vm.form.organization._placeholder = strings.get('SELECT AN ORGANIZATION');
+
+ // TODO: org not returned via api endpoint, check on this
+ vm.form.organization._value = application.get('organization');
+
+ vm.form.organization._disabled = true;
+ if (isSuperuser || isOrgAdmin || (application.get('organization') === null && isCurrentAuthor)) {
+ vm.form.organization._disabled = false;
+ }
+
+ vm.form.name.required = true;
+ vm.form.organization.required = true;
+ vm.form.redirect_uris.required = true;
+
+ delete vm.form.name.help_text;
+
+ vm.form.save = data => {
+ const hiddenData = {
+ authorization_grant_type: 'implicit',
+ user: me.get('id'),
+ client_type: 'public'
+ };
+
+ const payload = _.merge(data, hiddenData);
+
+ return application.request('put', { data: payload });
+ };
+
+ vm.form.onSaveSuccess = () => {
+ $state.go('applications.edit', { application_id: application.get('id') }, { reload: true });
+ };
+}
+
+EditApplicationsController.$inject = [
+ 'resolvedModels',
+ '$state',
+ 'ApplicationsStrings',
+ '$scope'
+];
+
+export default EditApplicationsController;
diff --git a/awx/ui/client/features/applications/index.js b/awx/ui/client/features/applications/index.js
new file mode 100644
index 0000000000..ca70be6165
--- /dev/null
+++ b/awx/ui/client/features/applications/index.js
@@ -0,0 +1,325 @@
+
+import AddController from './add-applications.controller';
+import EditController from './edit-applications.controller';
+import ListController from './list-applications.controller';
+import UserListController from './list-applications-users.controller';
+import ApplicationsStrings from './applications.strings';
+
+const MODULE_NAME = 'at.features.applications';
+
+const addEditTemplate = require('~features/applications/add-edit-applications.view.html');
+const listTemplate = require('~features/applications/list-applications.view.html');
+const indexTemplate = require('~features/applications/index.view.html');
+const userListTemplate = require('~features/applications/list-applications-users.view.html');
+
+function ApplicationsDetailResolve ($q, $stateParams, Me, Application, Organization) {
+ const id = $stateParams.application_id;
+
+ const promises = {
+ me: new Me('get').then((me) => me.extend('get', 'admin_of_organizations'))
+ };
+
+ if (!id) {
+ promises.application = new Application('options');
+ promises.organization = new Organization();
+
+ return $q.all(promises);
+ }
+
+ promises.application = new Application(['get', 'options'], [id, id]);
+
+ return $q.all(promises)
+ .then(models => {
+ const orgId = models.application.get('organization');
+
+ const dependents = {
+ organization: new Organization('get', orgId)
+ };
+
+ return $q.all(dependents)
+ .then(related => {
+ models.organization = related.organization;
+
+ return models;
+ });
+ });
+}
+
+ApplicationsDetailResolve.$inject = [
+ '$q',
+ '$stateParams',
+ 'MeModel',
+ 'ApplicationModel',
+ 'OrganizationModel'
+];
+
+function ApplicationsRun ($stateExtender, strings) {
+ $stateExtender.addState({
+ name: 'applications',
+ route: '/applications',
+ ncyBreadcrumb: {
+ label: strings.get('state.LIST_BREADCRUMB_LABEL')
+ },
+ data: {
+ activityStream: true,
+ // TODO: double-check activity stream works
+ activityStreamTarget: 'application'
+ },
+ views: {
+ '@': {
+ templateUrl: indexTemplate,
+ },
+ 'list@applications': {
+ templateUrl: listTemplate,
+ controller: ListController,
+ controllerAs: 'vm'
+ }
+ },
+ searchPrefix: 'application',
+ resolve: {
+ resolvedModels: [
+ 'ApplicationModel',
+ (Application) => {
+ const app = new Application(['options']);
+ return app;
+ }
+ ],
+ Dataset: [
+ '$stateParams',
+ 'Wait',
+ 'GetBasePath',
+ 'QuerySet',
+ ($stateParams, Wait, GetBasePath, qs) => {
+ const searchParam = $stateParams.application_search;
+ const searchPath = GetBasePath('applications');
+
+ Wait('start');
+ return qs.search(searchPath, searchParam)
+ .finally(() => {
+ Wait('stop');
+ });
+ }
+ ],
+ }
+ });
+
+ $stateExtender.addState({
+ name: 'applications.add',
+ route: '/add',
+ ncyBreadcrumb: {
+ label: strings.get('state.ADD_BREADCRUMB_LABEL')
+ },
+ data: {
+ activityStream: true,
+ // TODO: double-check activity stream works
+ activityStreamTarget: 'application'
+ },
+ views: {
+ 'add@applications': {
+ templateUrl: addEditTemplate,
+ controller: AddController,
+ controllerAs: 'vm'
+ }
+ },
+ resolve: {
+ resolvedModels: ApplicationsDetailResolve
+ }
+ });
+
+ $stateExtender.addState({
+ name: 'applications.edit',
+ route: '/:application_id',
+ ncyBreadcrumb: {
+ label: strings.get('state.EDIT_BREADCRUMB_LABEL')
+ },
+ data: {
+ activityStream: true,
+ activityStreamTarget: 'application',
+ activityStreamId: 'application_id'
+ },
+ views: {
+ 'edit@applications': {
+ templateUrl: addEditTemplate,
+ controller: EditController,
+ controllerAs: 'vm'
+ }
+ },
+ resolve: {
+ resolvedModels: ApplicationsDetailResolve
+ }
+ });
+
+ $stateExtender.addState({
+ name: 'applications.add.organization',
+ url: '/organization?selected',
+ searchPrefix: 'organization',
+ params: {
+ organization_search: {
+ value: {
+ page_size: 5,
+ order_by: 'name',
+ role_level: 'admin_role'
+ },
+ dynamic: true,
+ squash: ''
+ }
+ },
+ data: {
+ basePath: 'organizations',
+ formChildState: true
+ },
+ ncyBreadcrumb: {
+ skip: true
+ },
+ views: {
+ 'organization@applications.add': {
+ templateProvider: (ListDefinition, generateList) => {
+ const html = generateList.build({
+ mode: 'lookup',
+ list: ListDefinition,
+ input_type: 'radio'
+ });
+
+ return `${html}`;
+ }
+ }
+ },
+ resolve: {
+ ListDefinition: ['OrganizationList', list => list],
+ Dataset: ['ListDefinition', 'QuerySet', '$stateParams', 'GetBasePath',
+ (list, qs, $stateParams, GetBasePath) => qs.search(
+ GetBasePath('organizations'),
+ $stateParams[`${list.iterator}_search`]
+ )
+ ]
+ },
+ onExit ($state) {
+ if ($state.transition) {
+ $('#form-modal').modal('hide');
+ $('.modal-backdrop').remove();
+ $('body').removeClass('modal-open');
+ }
+ }
+ });
+
+ $stateExtender.addState({
+ name: 'applications.edit.organization',
+ url: '/organization?selected',
+ searchPrefix: 'organization',
+ params: {
+ organization_search: {
+ value: {
+ page_size: 5,
+ order_by: 'name',
+ role_level: 'admin_role'
+ },
+ dynamic: true,
+ squash: ''
+ }
+ },
+ data: {
+ basePath: 'organizations',
+ formChildState: true
+ },
+ ncyBreadcrumb: {
+ skip: true
+ },
+ views: {
+ 'organization@applications.edit': {
+ templateProvider: (ListDefinition, generateList) => {
+ const html = generateList.build({
+ mode: 'lookup',
+ list: ListDefinition,
+ input_type: 'radio'
+ });
+
+ return `${html}`;
+ }
+ }
+ },
+ resolve: {
+ ListDefinition: ['OrganizationList', list => list],
+ Dataset: ['ListDefinition', 'QuerySet', '$stateParams', 'GetBasePath',
+ (list, qs, $stateParams, GetBasePath) => qs.search(
+ GetBasePath('organizations'),
+ $stateParams[`${list.iterator}_search`]
+ )
+ ]
+ },
+ onExit ($state) {
+ if ($state.transition) {
+ $('#form-modal').modal('hide');
+ $('.modal-backdrop').remove();
+ $('body').removeClass('modal-open');
+ }
+ }
+ });
+
+ $stateExtender.addState({
+ name: 'applications.edit.users',
+ route: '/users',
+ ncyBreadcrumb: {
+ label: strings.get('state.USER_LIST_BREADCRUMB_LABEL'),
+ parent: 'applications.edit'
+ },
+ data: {
+ activityStream: true,
+ // TODO: double-check activity stream works
+ activityStreamTarget: 'application'
+ },
+ views: {
+ 'userList@applications.edit': {
+ templateUrl: userListTemplate,
+ controller: UserListController,
+ controllerAs: 'vm'
+ }
+ },
+ params: {
+ user_search: {
+ value: {
+ order_by: 'user',
+ page_size: 20
+ },
+ dynamic: true
+ }
+ },
+ searchPrefix: 'user',
+ resolve: {
+ resolvedModels: [
+ 'ApplicationModel',
+ (Application) => {
+ const app = new Application(['options']);
+ return app;
+ }
+ ],
+ Dataset: [
+ '$stateParams',
+ 'Wait',
+ 'GetBasePath',
+ 'QuerySet',
+ ($stateParams, Wait, GetBasePath, qs) => {
+ const searchParam = $stateParams.user_search;
+ const searchPath = `${GetBasePath('applications')}${$stateParams.application_id}/tokens`;
+
+ Wait('start');
+ return qs.search(searchPath, searchParam)
+ .finally(() => {
+ Wait('stop');
+ });
+ }
+ ],
+ }
+ });
+}
+
+ApplicationsRun.$inject = [
+ '$stateExtender',
+ 'ApplicationsStrings'
+];
+
+angular
+ .module(MODULE_NAME, [])
+ .service('ApplicationsStrings', ApplicationsStrings)
+ .run(ApplicationsRun);
+
+export default MODULE_NAME;
diff --git a/awx/ui/client/features/applications/index.view.html b/awx/ui/client/features/applications/index.view.html
new file mode 100644
index 0000000000..b4135fb791
--- /dev/null
+++ b/awx/ui/client/features/applications/index.view.html
@@ -0,0 +1,3 @@
+
+
+
diff --git a/awx/ui/client/features/applications/list-applications-users.controller.js b/awx/ui/client/features/applications/list-applications-users.controller.js
new file mode 100644
index 0000000000..1c6856498b
--- /dev/null
+++ b/awx/ui/client/features/applications/list-applications-users.controller.js
@@ -0,0 +1,55 @@
+/** ***********************************************
+ * Copyright (c) 2018 Ansible, Inc.
+ *
+ * All Rights Reserved
+ ************************************************ */
+function ListApplicationsUsersController (
+ $filter,
+ $scope,
+ Dataset,
+ resolvedModels,
+ strings
+) {
+ const vm = this || {};
+ // const application = resolvedModels;
+
+ vm.strings = strings;
+ // smart-search
+ const name = 'users';
+ const iterator = 'user';
+ const key = 'user_dataset';
+
+ $scope.list = { iterator, name, basePath: 'applications' };
+ $scope.collection = { iterator };
+ $scope[key] = Dataset.data;
+ vm.usersCount = Dataset.data.count;
+ $scope[name] = Dataset.data.results;
+
+ vm.getLastUsed = user => {
+ const lastUsed = _.get(user, 'last_used');
+
+ if (!lastUsed) {
+ return undefined;
+ }
+
+ let html = $filter('longDate')(lastUsed);
+
+ const { username, id } = _.get(user, 'summary_fields.last_used', {});
+
+ if (username && id) {
+ html += ` by ${$filter('sanitize')(username)}`;
+ }
+
+ return html;
+ };
+}
+
+ListApplicationsUsersController.$inject = [
+ '$filter',
+ '$scope',
+ 'Dataset',
+ 'resolvedModels',
+ 'ApplicationsStrings'
+];
+
+export default ListApplicationsUsersController;
diff --git a/awx/ui/client/features/applications/list-applications-users.view.html b/awx/ui/client/features/applications/list-applications-users.view.html
new file mode 100644
index 0000000000..3acb66242d
--- /dev/null
+++ b/awx/ui/client/features/applications/list-applications-users.view.html
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/awx/ui/client/features/applications/list-applications.controller.js b/awx/ui/client/features/applications/list-applications.controller.js
new file mode 100644
index 0000000000..fd67589a08
--- /dev/null
+++ b/awx/ui/client/features/applications/list-applications.controller.js
@@ -0,0 +1,117 @@
+/** ***********************************************
+ * Copyright (c) 2018 Ansible, Inc.
+ *
+ * All Rights Reserved
+ ************************************************ */
+function ListApplicationsController (
+ $filter,
+ $scope,
+ $state,
+ Dataset,
+ ProcessErrors,
+ Prompt,
+ resolvedModels,
+ strings,
+ Wait,
+) {
+ const vm = this || {};
+ const application = resolvedModels;
+
+ vm.strings = strings;
+ vm.activeId = $state.params.application_id;
+
+ $scope.canAdd = application.options('actions.POST');
+
+ // smart-search
+ const name = 'applications';
+ const iterator = 'application';
+ const key = 'application_dataset';
+
+ $scope.list = { iterator, name, basePath: 'applications' };
+ $scope.collection = { iterator };
+ $scope[key] = Dataset.data;
+ vm.applicationsCount = Dataset.data.count;
+ $scope[name] = Dataset.data.results;
+ $scope.$on('updateDataset', (e, dataset) => {
+ $scope[key] = dataset;
+ $scope[name] = dataset.results;
+ vm.applicationsCount = dataset.count;
+ });
+
+ vm.getModified = app => {
+ const modified = _.get(app, 'modified');
+
+ if (!modified) {
+ return undefined;
+ }
+
+ let html = $filter('longDate')(modified);
+
+ const { username, id } = _.get(app, 'summary_fields.modified_by', {});
+
+ if (username && id) {
+ html += ` by ${$filter('sanitize')(username)}`;
+ }
+
+ return html;
+ };
+
+ vm.deleteApplication = (app) => {
+ const action = () => {
+ $('#prompt-modal').modal('hide');
+ Wait('start');
+ application.request('delete', app.id)
+ .then(() => {
+ let reloadListStateParams = null;
+
+ if ($scope.applications.length === 1 && $state.params.application_search &&
+ !_.isEmpty($state.params.application_search.page) &&
+ $state.params.application_search.page !== '1') {
+ const page = `${(parseInt(reloadListStateParams
+ .application_search.page, 10) - 1)}`;
+ reloadListStateParams = _.cloneDeep($state.params);
+ reloadListStateParams.application_search.page = page;
+ }
+
+ if (parseInt($state.params.application_id, 10) === app.id) {
+ $state.go('^', reloadListStateParams, { reload: true });
+ } else {
+ $state.go('.', reloadListStateParams, { reload: true });
+ }
+ })
+ .catch(({ data, status }) => {
+ ProcessErrors($scope, data, status, null, {
+ hdr: strings.get('error.HEADER'),
+ msg: strings.get('error.CALL', { path: `${application.path}${app.id}`, status })
+ });
+ })
+ .finally(() => {
+ Wait('stop');
+ });
+ };
+
+ const deleteModalBody = `${strings.get('deleteResource.CONFIRM', 'application')}
`;
+
+ Prompt({
+ hdr: strings.get('deleteResource.HEADER'),
+ resourceName: $filter('sanitize')(app.name),
+ body: deleteModalBody,
+ action,
+ actionText: 'DELETE'
+ });
+ };
+}
+
+ListApplicationsController.$inject = [
+ '$filter',
+ '$scope',
+ '$state',
+ 'Dataset',
+ 'ProcessErrors',
+ 'Prompt',
+ 'resolvedModels',
+ 'ApplicationsStrings',
+ 'Wait'
+];
+
+export default ListApplicationsController;
diff --git a/awx/ui/client/features/applications/list-applications.view.html b/awx/ui/client/features/applications/list-applications.view.html
new file mode 100644
index 0000000000..1803e5ab66
--- /dev/null
+++ b/awx/ui/client/features/applications/list-applications.view.html
@@ -0,0 +1,69 @@
+
+
+ APPLICATIONS
+
+ {{ vm.applicationsCount }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/awx/ui/client/features/index.js b/awx/ui/client/features/index.js
index 1cb67b73d4..75093bf39c 100644
--- a/awx/ui/client/features/index.js
+++ b/awx/ui/client/features/index.js
@@ -2,6 +2,7 @@ import atLibServices from '~services';
import atLibComponents from '~components';
import atLibModels from '~models';
+import atFeaturesApplications from '~features/applications';
import atFeaturesCredentials from '~features/credentials';
import atFeaturesTemplates from '~features/templates';
@@ -11,6 +12,7 @@ angular.module(MODULE_NAME, [
atLibServices,
atLibComponents,
atLibModels,
+ atFeaturesApplications,
atFeaturesCredentials,
atFeaturesTemplates
]);
diff --git a/awx/ui/client/lib/components/components.strings.js b/awx/ui/client/lib/components/components.strings.js
index a827ee890c..70c2a7d6de 100644
--- a/awx/ui/client/lib/components/components.strings.js
+++ b/awx/ui/client/lib/components/components.strings.js
@@ -73,6 +73,7 @@ function ComponentsStrings (BaseString) {
MANAGEMENT_JOBS: t.s('Management Jobs'),
INSTANCES: t.s('Instances'),
INSTANCE_GROUPS: t.s('Instance Groups'),
+ APPLICATIONS: t.s('Applications'),
SETTINGS: t.s('Settings'),
FOOTER_ABOUT: t.s('About'),
FOOTER_COPYRIGHT: t.s('Copyright © 2017 Red Hat, Inc.')
diff --git a/awx/ui/client/lib/components/layout/_index.less b/awx/ui/client/lib/components/layout/_index.less
index 96159da01e..fd4e29c144 100644
--- a/awx/ui/client/lib/components/layout/_index.less
+++ b/awx/ui/client/lib/components/layout/_index.less
@@ -110,6 +110,10 @@
padding: @at-padding-side-nav-item-icon;
}
+ i.fa-cubes {
+ margin-left: -4px;
+ }
+
&:hover,
&.is-active {
background: @at-color-side-nav-item-background-hover;
@@ -119,12 +123,16 @@
color: @at-color-side-nav-content;
margin-left: @at-highlight-left-border-margin-makeup;
}
+
+ i.fa-cubes {
+ margin-left: -9px;
+ }
}
}
.at-Layout-sideNavSpacer {
background: inherit;
- height: @at-height-side-nav-spacer;
+ height: 5px;
}
&--expanded {
@@ -213,4 +221,4 @@
}
}
}
-}
\ No newline at end of file
+}
diff --git a/awx/ui/client/lib/components/layout/layout.partial.html b/awx/ui/client/lib/components/layout/layout.partial.html
index 0ffa4a1ad1..b1ada5e000 100644
--- a/awx/ui/client/lib/components/layout/layout.partial.html
+++ b/awx/ui/client/lib/components/layout/layout.partial.html
@@ -71,6 +71,9 @@
system-admin-only="true">
+
+
diff --git a/awx/ui/client/lib/components/list/list.directive.js b/awx/ui/client/lib/components/list/list.directive.js
index 28c3c546ea..6ff88e506f 100644
--- a/awx/ui/client/lib/components/list/list.directive.js
+++ b/awx/ui/client/lib/components/list/list.directive.js
@@ -1,6 +1,10 @@
const templateUrl = require('~components/list/list.partial.html');
-// TODO: figure out emptyListReason scope property
+function atListLink (scope, element, attrs) {
+ if (!attrs.results) {
+ throw new Error('at-list directive requires results attr to set up the empty list properly');
+ }
+}
function AtListController (strings) {
this.strings = strings;
@@ -17,6 +21,7 @@ function atList () {
scope: {
results: '=',
},
+ link: atListLink,
controller: AtListController,
controllerAs: 'vm',
};
diff --git a/awx/ui/client/lib/models/Application.js b/awx/ui/client/lib/models/Application.js
new file mode 100644
index 0000000000..5c8cdbd066
--- /dev/null
+++ b/awx/ui/client/lib/models/Application.js
@@ -0,0 +1,51 @@
+let Base;
+
+function createFormSchema (method, config) {
+ if (!config) {
+ config = method;
+ method = 'GET';
+ }
+
+ const schema = Object.assign({}, this.options(`actions.${method.toUpperCase()}`));
+
+ if (config && config.omit) {
+ config.omit.forEach(key => delete schema[key]);
+ }
+
+ Object.keys(schema).forEach(key => {
+ schema[key].id = key;
+
+ if (this.has(key)) {
+ schema[key]._value = this.get(key);
+ }
+ });
+
+ return schema;
+}
+
+function setDependentResources () {
+ this.dependentResources = [];
+}
+
+function ApplicationModel (method, resource, config) {
+ // TODO: change to applications
+ Base.call(this, 'applications');
+
+ this.Constructor = ApplicationModel;
+ this.createFormSchema = createFormSchema.bind(this);
+ this.setDependentResources = setDependentResources.bind(this);
+
+ return this.create(method, resource, config);
+}
+
+function ApplicationModelLoader (BaseModel) {
+ Base = BaseModel;
+
+ return ApplicationModel;
+}
+
+ApplicationModelLoader.$inject = [
+ 'BaseModel',
+];
+
+export default ApplicationModelLoader;
diff --git a/awx/ui/client/lib/models/Base.js b/awx/ui/client/lib/models/Base.js
index 01392eafb9..a2c202aa79 100644
--- a/awx/ui/client/lib/models/Base.js
+++ b/awx/ui/client/lib/models/Base.js
@@ -361,7 +361,11 @@ function normalizePath (resource) {
}
function isEditable () {
- const canEdit = this.get('summary_fields.user_capabilities.edit');
+ let canEdit = this.get('summary_fields.user_capabilities.edit');
+
+ if (canEdit === undefined) {
+ canEdit = true;
+ }
if (canEdit) {
return true;
diff --git a/awx/ui/client/lib/models/index.js b/awx/ui/client/lib/models/index.js
index fdecf596fd..9f3e57b3cd 100644
--- a/awx/ui/client/lib/models/index.js
+++ b/awx/ui/client/lib/models/index.js
@@ -1,5 +1,6 @@
import atLibServices from '~services';
+import Application from '~models/Application';
import Base from '~models/Base';
import Config from '~models/Config';
import Credential from '~models/Credential';
@@ -28,6 +29,7 @@ angular
.module(MODULE_NAME, [
atLibServices
])
+ .service('ApplicationModel', Application)
.service('BaseModel', Base)
.service('ConfigModel', Config)
.service('CredentialModel', Credential)
diff --git a/awx/ui/client/src/app.js b/awx/ui/client/src/app.js
index 0fd75d8280..ab93141b7d 100644
--- a/awx/ui/client/src/app.js
+++ b/awx/ui/client/src/app.js
@@ -316,8 +316,23 @@ angular
activateTab();
});
- $transitions.onSuccess({}, function(trans) {
+ $transitions.onCreate({}, function(trans) {
+ console.log('$onCreate ' +trans.to().name);
+ });
+ $transitions.onBefore({}, function(trans) {
+ console.log('$onBefore ' +trans.to().name);
+ });
+ $transitions.onError({}, function(trans) {
+
+ console.log('$onError ' +trans.to().name);
+ });
+ $transitions.onExit({}, function(trans) {
+ console.log('$onExit ' +trans.to().name);
+ });
+
+ $transitions.onSuccess({}, function(trans) {
+ console.log('$onSuccess ' +trans.to().name);
if(trans.to() === trans.from()) {
// check to see if something other than a search param has changed
let toParamsWithoutSearchKeys = {};