diff --git a/addons/core/addon/components/dropdown/index.hbs b/addons/core/addon/components/dropdown/index.hbs index 1a9e185cb..a17ef41b9 100644 --- a/addons/core/addon/components/dropdown/index.hbs +++ b/addons/core/addon/components/dropdown/index.hbs @@ -7,6 +7,7 @@ @height='314px' @onClose={{this.close}} @listPosition={{@listPosition}} + name={{@name}} as |DD| > ({ + id: type, + name: this.intl.t(`resources.target.types.${type}`), + })); + } + + // =methods + + /** + * Handles input on each keystroke and the search queryParam + * @param {object} event + */ + @action + @debounce(250) + handleSearchInput(event) { + const { value } = event.target; + this.search = value; + this.page = 1; + } + + /** + * Sets the scopes query param to value of selectedScopes + * to trigger a query and closes the dropdown + * @param {function} close + */ + @action + applyFilter(paramKey, selectedItems) { + this[paramKey] = [...selectedItems]; + this.page = 1; + } +} diff --git a/ui/admin/app/routes/scopes/scope/targets.js b/ui/admin/app/routes/scopes/scope/targets.js index 76f587207..1a6f377d4 100644 --- a/ui/admin/app/routes/scopes/scope/targets.js +++ b/ui/admin/app/routes/scopes/scope/targets.js @@ -13,7 +13,6 @@ import { notifySuccess, notifyError } from 'core/decorators/notify'; export default class ScopesScopeTargetsRoute extends Route { // =services - @service store; @service intl; @service session; @service can; @@ -29,18 +28,6 @@ export default class ScopesScopeTargetsRoute extends Route { if (!this.session.isAuthenticated) this.router.transitionTo('index'); } - /** - * Loads all targets under current scope. - * @return {Promise{[TargetModel]}} - */ - async model() { - const scope = this.modelFor('scopes.scope'); - const { id: scope_id } = scope; - if (this.can.can('list model', scope, { collection: 'targets' })) { - return this.store.query('target', { scope_id }); - } - } - // =actions /** @@ -149,7 +136,8 @@ export default class ScopesScopeTargetsRoute extends Route { @notifySuccess('notifications.delete-success') async delete(target) { await target.destroyRecord(); - await this.router.replaceWith('scopes.scope.targets'); + // awaiting this transition causes a transitionAborted error to bubble up + this.router.replaceWith('scopes.scope.targets'); this.refresh(); } } diff --git a/ui/admin/app/routes/scopes/scope/targets/index.js b/ui/admin/app/routes/scopes/scope/targets/index.js index a275a612b..9a8006f40 100644 --- a/ui/admin/app/routes/scopes/scope/targets/index.js +++ b/ui/admin/app/routes/scopes/scope/targets/index.js @@ -4,9 +4,145 @@ */ import Route from '@ember/routing/route'; +import { inject as service } from '@ember/service'; +import { + STATUS_SESSION_ACTIVE, + STATUS_SESSION_PENDING, +} from 'api/models/session'; + export default class ScopesScopeTargetsIndexRoute extends Route { + // =services + + @service can; + @service store; + @service session; + + // =attributes + + queryParams = { + search: { + refreshModel: true, + replace: true, + }, + availableSessions: { + refreshModel: true, + replace: true, + }, + types: { + refreshModel: true, + replace: true, + }, + page: { + refreshModel: true, + }, + pageSize: { + refreshModel: true, + }, + }; + // =methods + /** + * Loads all targets under current scope. + * @return {Promise{[TargetModel]}} + */ + async model({ search, availableSessions, types, page, pageSize }) { + const scope = this.modelFor('scopes.scope'); + const { id: scope_id } = scope; + + const filters = { + scope_id: [{ equals: scope_id }], + id: { values: [] }, + type: [], + }; + types.forEach((type) => { + filters.type.push({ equals: type }); + }); + + const sessions = await this.store.query('session', { + query: { + filters: { + scope_id: [{ equals: scope_id }], + status: [ + { equals: STATUS_SESSION_ACTIVE }, + { equals: STATUS_SESSION_PENDING }, + ], + }, + }, + }); + this.addActiveSessionFilters(filters, availableSessions, sessions); + + await this.getAllTargets(scope_id); + + let targets; + let totalItems = 0; + if (this.can.can('list model', scope, { collection: 'targets' })) { + targets = await this.store.query('target', { + query: { search, filters }, + page, + pageSize, + }); + totalItems = targets.meta?.totalItems; + } + + return { targets, allTargets: this.allTargets, totalItems }; + } + + async getAllTargets(scopeId) { + const options = { pushToStore: false }; + this.allTargets = await this.store.query( + 'target', + { + query: { + filters: { + scope_id: [{ equals: scopeId }], + }, + }, + }, + options, + ); + } + + /** + * Add the filters for active sessions to the filter object. + * @param filters + * @param availableSessions + * @param sessions + */ + addActiveSessionFilters = (filters, availableSessions, sessions) => { + const uniqueTargetIdsWithSessions = new Set( + sessions.map((session) => session.target_id), + ); + + // Don't add any filtering if the user selects both which is equivalent to no filters + if (availableSessions.length === 2) { + return; + } + + availableSessions.forEach((availability) => { + if (availability === 'yes') { + filters.id.logicalOperator = 'or'; + uniqueTargetIdsWithSessions.forEach((targetId) => { + filters.id.values.push({ equals: targetId }); + }); + + // If there's no sessions just set it to a dummy value + // so the search returns no results + if (uniqueTargetIdsWithSessions.size === 0) { + filters.id.values.push({ equals: 'none' }); + } + } + + if (availability === 'no') { + filters.id.logicalOperator = 'and'; + + uniqueTargetIdsWithSessions.forEach((targetId) => { + filters.id.values.push({ notEquals: targetId }); + }); + } + }); + }; + setupController(controller) { const scope = this.modelFor('scopes.scope'); super.setupController(...arguments); diff --git a/ui/admin/app/templates/scopes/scope/targets/index.hbs b/ui/admin/app/templates/scopes/scope/targets/index.hbs index 387fa55fa..f7f223769 100644 --- a/ui/admin/app/templates/scopes/scope/targets/index.hbs +++ b/ui/admin/app/templates/scopes/scope/targets/index.hbs @@ -14,66 +14,64 @@ {{t 'resources.target.title_plural'}} - {{#if @model}} + {{#if @model.allTargets}}

{{t 'resources.target.description'}}

{{/if}} {{#if (can 'create model' this.scope collection='targets')}} - {{#if @model}} + {{#if @model.allTargets}} {{t 'resources.target.titles.new' }} {{/if}} {{/if}} - + + {{#if @model.allTargets}} + + + + + + {{#if (feature-flag 'ssh-target')}} + + + + {{/if}} + + - {{#if @model}} - - - <:body as |B|> - {{#if (feature-flag 'ssh-target')}} - - - {{#if (can 'read model' B.data)}} - - {{B.data.displayName}} - - {{else}} - {{B.data.displayName}} - {{/if}} -

{{B.data.description}}

-
- - {{#if B.data.type}} - - {{/if}} - - - - -
- {{else}} - {{#if B.data.isTCP}} + {{#if @model.targets}} + + <:body as |B|> + {{#if (feature-flag 'ssh-target')}} {{#if (can 'read model' B.data)}} @@ -102,50 +100,94 @@ /> - {{/if}} - {{/if}} - - - - {{else}} - - - - - - {{#if (can 'list model' this.scope collection='targets')}} - {{! can list (at least): show default welcome message}} - {{t 'resources.target.description'}} - {{else if (can 'create model' this.scope collection='targets')}} - {{! can create (only): show create-but-not-list welcome message}} - {{t - 'descriptions.create-but-not-list' - resource=(t 'resources.target.title_plural') - }} {{else}} - {{! can neither list nor create }} - {{t - 'descriptions.neither-list-nor-create' - resource=(t 'resources.target.title_plural') - }} - + {{#if B.data.isTCP}} + + + {{#if (can 'read model' B.data)}} + + {{B.data.displayName}} + + {{else}} + {{B.data.displayName}} + {{/if}} +

{{B.data.description}}

+
+ + {{#if B.data.type}} + + {{/if}} + + + + +
+ {{/if}} {{/if}} -
- - {{#if (can 'create model' this.scope collection='targets')}} - - - {{t 'titles.new'}} - - {{/if}} -
-
+ +
+ + {{else}} + + + + + {{/if}} + {{else}} + + + + {{#if (can 'create model' this.scope collection='targets')}} + + + + {{/if}} + {{/if}} -
\ No newline at end of file diff --git a/ui/admin/tests/acceptance/targets/delete-test.js b/ui/admin/tests/acceptance/targets/delete-test.js index 8ea91d42c..44efc3234 100644 --- a/ui/admin/tests/acceptance/targets/delete-test.js +++ b/ui/admin/tests/acceptance/targets/delete-test.js @@ -7,6 +7,7 @@ import { module, test } from 'qunit'; import { visit, click, currentURL } from '@ember/test-helpers'; import { setupApplicationTest } from 'ember-qunit'; import setupMirage from 'ember-cli-mirage/test-support/setup-mirage'; +import { setupIndexedDb } from 'api/test-support/helpers/indexed-db'; import { Response } from 'miragejs'; import { authenticateSession, @@ -18,6 +19,7 @@ import { module('Acceptance | targets | delete', function (hooks) { setupApplicationTest(hooks); setupMirage(hooks); + setupIndexedDb(hooks); let getTargetCount; diff --git a/ui/admin/tests/acceptance/targets/list-test.js b/ui/admin/tests/acceptance/targets/list-test.js index d0b56cbf7..c3bf05be5 100644 --- a/ui/admin/tests/acceptance/targets/list-test.js +++ b/ui/admin/tests/acceptance/targets/list-test.js @@ -4,19 +4,35 @@ */ import { module, test } from 'qunit'; -import { visit, click } from '@ember/test-helpers'; +import { visit, click, fillIn, waitUntil, findAll } from '@ember/test-helpers'; import { setupApplicationTest } from 'ember-qunit'; import setupMirage from 'ember-cli-mirage/test-support/setup-mirage'; +import { setupIndexedDb } from 'api/test-support/helpers/indexed-db'; import { authenticateSession, // These are left here intentionally for future reference. //currentSession, //invalidateSession, } from 'ember-simple-auth/test-support'; +import { TYPE_TARGET_TCP, TYPE_TARGET_SSH } from 'api/models/target'; module('Acceptance | targets | list', function (hooks) { setupApplicationTest(hooks); setupMirage(hooks); + setupIndexedDb(hooks); + + const SEARCH_INPUT_SELECTOR = '.search-filtering [type="search"]'; + const NO_RESULTS_MSG_SELECTOR = '[data-test-no-target-results]'; + const TYPE_FILTER_DROPDOWN_SELECTOR = + '.search-filtering [name="Type"] button'; + const TCP_TYPE_FILTER_OPTION_SELECTOR = + '.search-filtering [name="Type"] [data-test-checkbox="tcp"]'; + const FILTER_APPLY_BUTTON_SELECTOR = + '.search-filtering [data-test-dropdown-apply-button]'; + const ACTIVE_SESSIONS_FILTER_DROPDOWN_SELECTOR = + '.search-filtering [name="Active sessions"] button'; + const YES_FILTER_OPTION_SELECTOR = + '.search-filtering [name="Active sessions"] [data-test-checkbox="yes"]'; const instances = { scopes: { @@ -24,13 +40,16 @@ module('Acceptance | targets | list', function (hooks) { org: null, project: null, }, - target: null, + tcpTarget: null, + sshTarget: null, }; const urls = { orgScope: null, projectScope: null, targets: null, + tcpTarget: null, + sshTarget: null, }; hooks.beforeEach(function () { @@ -43,12 +62,23 @@ module('Acceptance | targets | list', function (hooks) { type: 'project', scope: { id: instances.scopes.org.id, type: 'org' }, }); - instances.target = this.server.create('target', { + instances.tcpTarget = this.server.create('target', { + type: TYPE_TARGET_TCP, + scope: instances.scopes.project, + }); + instances.sshTarget = this.server.create('target', { + id: 'target-1', + type: TYPE_TARGET_SSH, scope: instances.scopes.project, }); urls.orgScope = `/scopes/${instances.scopes.org.id}/scopes`; urls.projectScope = `/scopes/${instances.scopes.project.id}`; urls.targets = `${urls.projectScope}/targets`; + urls.tcpTarget = `${urls.targets}/${instances.tcpTarget.id}`; + urls.sshTarget = `${urls.targets}/${instances.sshTarget.id}`; + + const featuresService = this.owner.lookup('service:features'); + featuresService.enable('ssh-target'); authenticateSession({}); }); @@ -134,4 +164,70 @@ module('Acceptance | targets | list', function (hooks) { ); assert.dom(`[href="${urls.targets}"]`).exists(); }); + + test('user can search for a specifc target by id', async function (assert) { + await visit(urls.projectScope); + + await click(`[href="${urls.targets}"]`); + + assert.dom(`[href="${urls.tcpTarget}"]`).exists(); + assert.dom(`[href="${urls.sshTarget}"]`).exists(); + + await fillIn(SEARCH_INPUT_SELECTOR, instances.sshTarget.id); + await waitUntil(() => findAll(`[href="${urls.tcpTarget}"]`).length === 0); + + assert.dom(`[href="${urls.sshTarget}"]`).exists(); + assert.dom(`[href="${urls.tcpTarget}"]`).doesNotExist(); + }); + + test('user can search for targets and get no results', async function (assert) { + await visit(urls.projectScope); + + await click(`[href="${urls.targets}"]`); + + assert.dom(`[href="${urls.tcpTarget}"]`).exists(); + assert.dom(`[href="${urls.sshTarget}"]`).exists(); + + await fillIn(SEARCH_INPUT_SELECTOR, 'fake target that does not exist'); + await waitUntil(() => findAll(NO_RESULTS_MSG_SELECTOR).length === 1); + + assert.dom(`[href="${urls.sshTarget}"]`).doesNotExist(); + assert.dom(`[href="${urls.tcpTarget}"]`).doesNotExist(); + assert.dom(NO_RESULTS_MSG_SELECTOR).includesText('No results found'); + }); + + test('user can filter for targets by type', async function (assert) { + await visit(urls.projectScope); + + await click(`[href="${urls.targets}"]`); + + assert.dom(`[href="${urls.tcpTarget}"]`).exists(); + assert.dom(`[href="${urls.sshTarget}"]`).exists(); + + await click(TYPE_FILTER_DROPDOWN_SELECTOR); + await click(TCP_TYPE_FILTER_OPTION_SELECTOR); + await click(FILTER_APPLY_BUTTON_SELECTOR); + await waitUntil(() => findAll(`[href="${urls.sshTarget}"]`).length === 0); + + assert.dom(`[href="${urls.sshTarget}"]`).doesNotExist(); + assert.dom(`[href="${urls.tcpTarget}"]`).exists(); + }); + + test('user can filter for targets by active sessions', async function (assert) { + await visit(urls.projectScope); + + await click(`[href="${urls.targets}"]`); + + assert.dom(`[href="${urls.tcpTarget}"]`).exists(); + assert.dom(`[href="${urls.sshTarget}"]`).exists(); + + await click(ACTIVE_SESSIONS_FILTER_DROPDOWN_SELECTOR); + await click(YES_FILTER_OPTION_SELECTOR); + await click(FILTER_APPLY_BUTTON_SELECTOR); + await waitUntil(() => findAll(NO_RESULTS_MSG_SELECTOR).length === 1); + + assert.dom(`[href="${urls.sshTarget}"]`).doesNotExist(); + assert.dom(`[href="${urls.tcpTarget}"]`).doesNotExist(); + assert.dom(NO_RESULTS_MSG_SELECTOR).includesText('No results found'); + }); }); diff --git a/ui/admin/tests/acceptance/targets/read-test.js b/ui/admin/tests/acceptance/targets/read-test.js index 09e012516..c3ee2b7c0 100644 --- a/ui/admin/tests/acceptance/targets/read-test.js +++ b/ui/admin/tests/acceptance/targets/read-test.js @@ -7,6 +7,7 @@ import { module, test } from 'qunit'; import { visit, currentURL, click, find } from '@ember/test-helpers'; import { setupApplicationTest } from 'ember-qunit'; import setupMirage from 'ember-cli-mirage/test-support/setup-mirage'; +import { setupIndexedDb } from 'api/test-support/helpers/indexed-db'; import a11yAudit from 'ember-a11y-testing/test-support/audit'; import { authenticateSession, @@ -19,6 +20,7 @@ import { TYPE_TARGET_TCP, TYPE_TARGET_SSH } from 'api/models/target'; module('Acceptance | targets | read', function (hooks) { setupApplicationTest(hooks); setupMirage(hooks); + setupIndexedDb(hooks); let featuresService; const ALIASES_SIDEBAR = '.target-sidebar-aliases'; diff --git a/ui/admin/tests/acceptance/targets/update-test.js b/ui/admin/tests/acceptance/targets/update-test.js index 8c60dec45..2fdad4887 100644 --- a/ui/admin/tests/acceptance/targets/update-test.js +++ b/ui/admin/tests/acceptance/targets/update-test.js @@ -7,6 +7,7 @@ import { module, test } from 'qunit'; import { visit, currentURL, click, fillIn } from '@ember/test-helpers'; import { setupApplicationTest } from 'ember-qunit'; import setupMirage from 'ember-cli-mirage/test-support/setup-mirage'; +import { setupIndexedDb } from 'api/test-support/helpers/indexed-db'; import { Response } from 'miragejs'; import { authenticateSession, @@ -18,6 +19,7 @@ import { module('Acceptance | targets | update', function (hooks) { setupApplicationTest(hooks); setupMirage(hooks); + setupIndexedDb(hooks); let featuresService; diff --git a/ui/admin/tests/unit/controllers/scopes/scope/targets/index-test.js b/ui/admin/tests/unit/controllers/scopes/scope/targets/index-test.js new file mode 100644 index 000000000..3ea5e49c5 --- /dev/null +++ b/ui/admin/tests/unit/controllers/scopes/scope/targets/index-test.js @@ -0,0 +1,54 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; +import { waitUntil } from '@ember/test-helpers'; +import { TYPE_TARGET_TCP, TYPE_TARGET_SSH } from 'api/models/target'; + +module('Unit | Controller | scopes/scope/targets/index', function (hooks) { + setupTest(hooks); + + test('it exists', function (assert) { + let controller = this.owner.lookup('controller:scopes/scope/targets/index'); + assert.ok(controller); + }); + + test('availableSessionOptions returns expected object', function (assert) { + let controller = this.owner.lookup('controller:scopes/scope/targets/index'); + assert.deepEqual(controller.availableSessionOptions, [ + { id: 'yes', name: 'Yes' }, + { id: 'no', name: 'No' }, + ]); + }); + + test('targetTypeOptions returns expected object', function (assert) { + let controller = this.owner.lookup('controller:scopes/scope/targets/index'); + assert.deepEqual(controller.targetTypeOptions, [ + { id: TYPE_TARGET_TCP, name: 'Generic TCP' }, + { id: TYPE_TARGET_SSH, name: 'SSH' }, + ]); + }); + + test('filters returns expected entries', function (assert) { + let controller = this.owner.lookup('controller:scopes/scope/targets/index'); + assert.ok(controller.filters.allFilters); + assert.ok(controller.filters.selectedFilters); + }); + + test('handleSearchInput action sets expected values correctly', async function (assert) { + let controller = this.owner.lookup('controller:scopes/scope/targets/index'); + const searchValue = 'test'; + controller.handleSearchInput({ target: { value: searchValue } }); + await waitUntil(() => controller.search === searchValue); + + assert.strictEqual(controller.page, 1); + assert.strictEqual(controller.search, searchValue); + }); + + test('applyFilter action sets expected values correctly', async function (assert) { + let controller = this.owner.lookup('controller:scopes/scope/targets/index'); + const selectedItems = ['yes']; + controller.applyFilter('availableSessions', selectedItems); + + assert.strictEqual(controller.page, 1); + assert.deepEqual(controller.availableSessions, selectedItems); + }); +});