diff --git a/addons/rose/addon/components/rose/filterable-dropdown/index.hbs b/addons/rose/addon/components/rose/filterable-dropdown/index.hbs
new file mode 100644
index 000000000..f168dc866
--- /dev/null
+++ b/addons/rose/addon/components/rose/filterable-dropdown/index.hbs
@@ -0,0 +1,51 @@
+{{!
+ Copyright (c) HashiCorp, Inc.
+ SPDX-License-Identifier: MPL-2.0
+}}
+
+
+
+ {{#if @isSearchable}}
+
+
+
+ {{/if}}
+ {{#unless this.itemOptions}}
+
+ {{/unless}}
+
+ {{#if (has-block)}}
+ {{yield DD this.selectItem this.itemOptions}}
+ {{else}}
+ {{#each this.itemOptions as |itemOption|}}
+
+ {{itemOption.displayName}}
+
+ {{/each}}
+ {{/if}}
+
+
+
+
+
\ No newline at end of file
diff --git a/addons/rose/addon/components/rose/filterable-dropdown/index.js b/addons/rose/addon/components/rose/filterable-dropdown/index.js
new file mode 100644
index 000000000..9d83b5d42
--- /dev/null
+++ b/addons/rose/addon/components/rose/filterable-dropdown/index.js
@@ -0,0 +1,83 @@
+/**
+ * Copyright (c) HashiCorp, Inc.
+ * SPDX-License-Identifier: MPL-2.0
+ */
+
+import Component from '@glimmer/component';
+import { action } from '@ember/object';
+import { tracked } from '@glimmer/tracking';
+import { debounce } from 'core/decorators/debounce';
+
+// TODO: Remove old Rose::Dropdown and rename this to Rose::Dropdown
+export default class RoseFilterableDropdownComponent extends Component {
+ // =attributes
+
+ @tracked selectedItems = [];
+ @tracked searchTerm;
+
+ /**
+ * Takes itemOptions and filters them by searchTerm if there is one
+ * @returns {[object]}
+ */
+ get itemOptions() {
+ let items = this.args.itemOptions;
+ if (this.searchTerm) {
+ const searchTerm = this.searchTerm.toLowerCase();
+ items = this.args.itemOptions.filter((item) => {
+ const isNameMatch = item.name?.toLowerCase().includes(searchTerm);
+ const isIdMatch = item.id.includes(this.searchTerm);
+ return isNameMatch || isIdMatch;
+ });
+ }
+ return items;
+ }
+
+ // =methods
+
+ /**
+ * Sets searchTerm to be used for filtering itemOptions
+ * @param {object} event
+ */
+ @action
+ @debounce(150)
+ filterItems(event) {
+ const { value } = event.target;
+ this.searchTerm = value;
+ }
+
+ /**
+ * Handles checkbox event changes for selectedItems
+ * @param {object} event
+ */
+ @action
+ selectItem(event) {
+ const { checked, value } = event.target;
+ if (checked) {
+ this.selectedItems = [...this.selectedItems, value];
+ } else {
+ this.selectedItems = this.selectedItems.filter(
+ (scope) => scope !== value,
+ );
+ }
+ }
+
+ /**
+ * Pass selectedItems to applyFilter callback and call onClose
+ * @param {function} close
+ */
+ @action
+ applyFilter(onClose) {
+ this.args.applyFilter(this.selectedItems);
+ onClose();
+ }
+
+ /**
+ * Custom close method for dropdown that resets selectedItems
+ * to checkedItems when the user closes the dropdown
+ */
+ @action
+ close() {
+ this.selectedItems = [...this.args.checkedItems];
+ this.searchTerm = '';
+ }
+}
diff --git a/addons/rose/app/components/rose/filterable-dropdown/index.js b/addons/rose/app/components/rose/filterable-dropdown/index.js
new file mode 100644
index 000000000..3c877eb7b
--- /dev/null
+++ b/addons/rose/app/components/rose/filterable-dropdown/index.js
@@ -0,0 +1 @@
+export { default } from 'rose/components/rose/filterable-dropdown/index';
diff --git a/addons/rose/tests/integration/components/rose/filterable-dropdown/index-test.js b/addons/rose/tests/integration/components/rose/filterable-dropdown/index-test.js
new file mode 100644
index 000000000..826d3798e
--- /dev/null
+++ b/addons/rose/tests/integration/components/rose/filterable-dropdown/index-test.js
@@ -0,0 +1,25 @@
+import { module, test } from 'qunit';
+import { setupRenderingTest } from 'ember-qunit';
+import { render } from '@ember/test-helpers';
+import { hbs } from 'ember-cli-htmlbars';
+
+module(
+ 'Integration | Component | rose/filterable-dropdown/index',
+ function (hooks) {
+ setupRenderingTest(hooks);
+
+ test.skip('it renders', async function (assert) {
+ await render(hbs``);
+
+ assert.dom(this.element).hasText('');
+
+ await render(hbs`
+
+ template block text
+
+ `);
+
+ assert.dom(this.element).hasText('template block text');
+ });
+ },
+);
diff --git a/ui/desktop/app/controllers/scopes/scope/projects/targets/index.js b/ui/desktop/app/controllers/scopes/scope/projects/targets/index.js
index 7656ac777..e1a58f5ab 100644
--- a/ui/desktop/app/controllers/scopes/scope/projects/targets/index.js
+++ b/ui/desktop/app/controllers/scopes/scope/projects/targets/index.js
@@ -21,12 +21,12 @@ export default class ScopesScopeProjectsTargetsIndexController extends Controlle
// =attributes
- queryParams = ['search', 'page', 'pageSize'];
+ queryParams = ['search', { scopes: { type: 'array' } }, 'page', 'pageSize'];
@tracked search;
+ @tracked scopes = [];
@tracked page = 1;
@tracked pageSize = 10;
- @tracked searchItems = ['Project 1', 'Project 2', 'Project 3'];
// =methods
@@ -46,6 +46,22 @@ export default class ScopesScopeProjectsTargetsIndexController extends Controlle
return this.model.targets.length === 0 && !this.search;
}
+ /**
+ * Returns scopes that are associated with all targets user has access to
+ * @returns {[ScopeModel]}
+ */
+ get availableScopes() {
+ const targetScopeIds = this.model.allTargets.map(
+ (target) => target.scope.id,
+ );
+
+ let availableScopes = this.model.projects.filter((project) =>
+ targetScopeIds.includes(project.id),
+ );
+
+ return availableScopes;
+ }
+
/**
* Quick connect method used to call main connect method and handle
* connection errors unique to this route
@@ -142,4 +158,14 @@ export default class ScopesScopeProjectsTargetsIndexController extends Controlle
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(selectedItems) {
+ this.scopes = [...selectedItems];
+ }
}
diff --git a/ui/desktop/app/routes/scopes/scope/projects/sessions.js b/ui/desktop/app/routes/scopes/scope/projects/sessions.js
index ba02cbd35..099a6a30e 100644
--- a/ui/desktop/app/routes/scopes/scope/projects/sessions.js
+++ b/ui/desktop/app/routes/scopes/scope/projects/sessions.js
@@ -53,7 +53,7 @@ export default class ScopesScopeProjectsSessionsRoute extends Route {
this.router.currentRoute.name ===
'scopes.scope.projects.sessions.session.index'
) {
- this.router.replaceWith('scopes.scope.projects.targets');
+ this.router.replaceWith('scopes.scope.projects.targets.index');
}
}
diff --git a/ui/desktop/app/routes/scopes/scope/projects/targets/index.js b/ui/desktop/app/routes/scopes/scope/projects/targets/index.js
index 9243f2361..2544d82a3 100644
--- a/ui/desktop/app/routes/scopes/scope/projects/targets/index.js
+++ b/ui/desktop/app/routes/scopes/scope/projects/targets/index.js
@@ -23,6 +23,10 @@ export default class ScopesScopeProjectsTargetsIndexRoute extends Route {
refreshModel: true,
replace: true,
},
+ scopes: {
+ refreshModel: true,
+ replace: true,
+ },
page: {
refreshModel: true,
},
@@ -41,19 +45,25 @@ export default class ScopesScopeProjectsTargetsIndexRoute extends Route {
}
/**
- * Loads all targets and the number of targets under current scope.
+ * Loads queried targets, the number of targets under current scope, and
+ * projects for filtering options.
*
- * @returns {Promise<{totalItems: number, targets: [TargetModel]}>}
+ * @returns {Promise<{totalItems: number, targets: [TargetModel], projects: [ScopeModel]}>, allTargets: [TargetModel]}
*/
- async model({ search, page, pageSize }) {
+ async model({ search, scopes, page, pageSize }) {
// TODO: Filter targets by scope we're in manually
// const { id: scope_id } = this.modelFor('scopes.scope');
+ const filters = { scope_id: [] };
+ scopes.forEach((scope) => {
+ filters.scope_id.push({ equals: scope });
+ });
const queryOptions = {
- query: { search },
+ query: { search, filters },
page,
pageSize,
};
+ const projects = this.modelFor('scopes.scope.projects');
let targets = await this.store.query('target', queryOptions);
const totalItems = targets.meta?.totalItems;
@@ -63,6 +73,15 @@ export default class ScopesScopeProjectsTargetsIndexRoute extends Route {
this.can.can('connect target', target),
);
- return { targets: targets, totalItems };
+ // Query all targets for defining filtering values
+ const options = { pushToStore: false };
+ let allTargets = await this.store.query('target', {}, options);
+
+ // Filter out targets to which users do not have the connect ability
+ allTargets = allTargets.filter((target) =>
+ target.authorized_actions.includes('authorize-session'),
+ );
+
+ return { targets, projects, allTargets, totalItems };
}
}
diff --git a/ui/desktop/app/templates/scopes/scope/projects/targets/index.hbs b/ui/desktop/app/templates/scopes/scope/projects/targets/index.hbs
index 64cd48af3..c22282ee7 100644
--- a/ui/desktop/app/templates/scopes/scope/projects/targets/index.hbs
+++ b/ui/desktop/app/templates/scopes/scope/projects/targets/index.hbs
@@ -22,25 +22,35 @@
aria-label={{t 'actions.search'}}
{{on 'input' this.handleSearchInput}}
/>
-
-
-
-
-
- {{#each this.searchItems as |item|}}
-
- {{item}}
-
- {{/each}}
-
+
+
+ {{#let (group-by itemOptions 'scopeModel') as |orgs|}}
+ {{#each orgs as |org|}}
+
+
+ {{org.key.displayName}}
+
+ {{#each org.items as |project|}}
+
+ {{project.displayName}}
+
+ {{/each}}
+ {{/each}}
+ {{/let}}
+
+
{{t 'actions.yes'}}
@@ -111,7 +121,9 @@
{{B.data.project.id}}
-
+ {{#if B.data.project.name}}
+
+ {{/if}}
{{#if (can 'connect target' B.data)}}
diff --git a/ui/desktop/tests/acceptance/projects/targets/index-test.js b/ui/desktop/tests/acceptance/projects/targets/index-test.js
index 264b96689..4e4826d0c 100644
--- a/ui/desktop/tests/acceptance/projects/targets/index-test.js
+++ b/ui/desktop/tests/acceptance/projects/targets/index-test.js
@@ -131,7 +131,7 @@ module('Acceptance | projects | targets | index', function (hooks) {
setDefaultClusterUrl(this);
this.ipcStub.withArgs('isClientDaemonRunning').returns(true);
- this.stubClientDaemonSearch('targets');
+ this.stubClientDaemonSearch('targets', 'targets');
});
test('visiting index while unauthenticated redirects to global authenticate method', async function (assert) {
@@ -172,7 +172,7 @@ module('Acceptance | projects | targets | index', function (hooks) {
test('visiting targets list view with no targets', async function (assert) {
assert.expect(1);
this.server.db.targets.remove();
- this.stubClientDaemonSearch('targets');
+ this.stubClientDaemonSearch('targets', 'targets');
await visit(urls.projects);
@@ -185,7 +185,7 @@ module('Acceptance | projects | targets | index', function (hooks) {
assert.expect(1);
instances.target.authorized_actions =
instances.target.authorized_actions.filter((item) => item !== 'read');
- this.stubClientDaemonSearch('targets');
+ this.stubClientDaemonSearch('targets', 'targets');
await visit(urls.projects);
@@ -216,7 +216,7 @@ module('Acceptance | projects | targets | index', function (hooks) {
instances.target.authorized_actions.filter(
(item) => item !== 'authorize-session',
);
- this.stubClientDaemonSearch('targets');
+ this.stubClientDaemonSearch('targets', 'targets');
await visit(urls.projects);
@@ -276,7 +276,7 @@ module('Acceptance | projects | targets | index', function (hooks) {
instances.target.authorized_actions.filter((item) => item !== 'read');
this.ipcStub.withArgs('cliExists').returns(true);
this.ipcStub.withArgs('connect').rejects();
- this.stubClientDaemonSearch('targets');
+ this.stubClientDaemonSearch('targets', 'targets');
const confirmService = this.owner.lookup('service:confirm');
confirmService.enabled = true;
diff --git a/ui/desktop/tests/acceptance/projects/targets/target-test.js b/ui/desktop/tests/acceptance/projects/targets/target-test.js
index 9c66d7aef..01f868ac9 100644
--- a/ui/desktop/tests/acceptance/projects/targets/target-test.js
+++ b/ui/desktop/tests/acceptance/projects/targets/target-test.js
@@ -124,7 +124,7 @@ module('Acceptance | projects | targets | target', function (hooks) {
setDefaultClusterUrl(this);
this.ipcStub.withArgs('isClientDaemonRunning').returns(true);
- this.stubClientDaemonSearch('targets');
+ this.stubClientDaemonSearch('targets', 'targets');
});
test('user can connect to a target with an address', async function (assert) {
diff --git a/ui/desktop/tests/acceptance/scopes-test.js b/ui/desktop/tests/acceptance/scopes-test.js
index e8c5489e2..ae5821ea7 100644
--- a/ui/desktop/tests/acceptance/scopes-test.js
+++ b/ui/desktop/tests/acceptance/scopes-test.js
@@ -129,7 +129,7 @@ module('Acceptance | scopes', function (hooks) {
setDefaultClusterUrl(this);
this.ipcStub.withArgs('isClientDaemonRunning').returns(true);
- this.stubClientDaemonSearch('targets');
+ this.stubClientDaemonSearch('targets', 'targets');
});
test('visiting index', async function (assert) {
@@ -180,7 +180,16 @@ module('Acceptance | scopes', function (hooks) {
test('can navigate among org scopes via header navigation', async function (assert) {
assert.expect(3);
- this.stubClientDaemonSearch('targets', 'targets', 'targets', 'targets');
+ this.stubClientDaemonSearch(
+ 'targets',
+ 'targets',
+ 'targets',
+ 'targets',
+ 'targets',
+ 'targets',
+ 'targets',
+ 'targets',
+ );
await visit(urls.targets);
await click('.rose-header-nav .rose-dropdown a:nth-of-type(2)');
@@ -216,7 +225,7 @@ module('Acceptance | scopes', function (hooks) {
test('visiting empty targets', async function (assert) {
assert.expect(1);
this.server.db.targets.remove();
- this.stubClientDaemonSearch('targets');
+ this.stubClientDaemonSearch('targets', 'targets');
await visit(urls.targets);