From 7af8e71a2df48d15e96c8e872d2387cffb0e659f Mon Sep 17 00:00:00 2001 From: Cameron Perera Date: Tue, 12 Dec 2023 17:19:39 -0600 Subject: [PATCH] Icu 11802 filter targets by project scope (#2022) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 🎸 add ability to filter targets by scope ✅ Closes: https://hashicorp.atlassian.net/browse/ICU-11802 * refactor: 💡 update model hook signature comment ✅ Closes: https://hashicorp.atlassian.net/browse/ICU-11802 * refactor: 💡 remove await from cancel session route transition ✅ Closes: https://hashicorp.atlassian.net/browse/ICU-11802 * refactor: 💡 add comments to new controller methods ✅ Closes: https://hashicorp.atlassian.net/browse/ICU-11802 * refactor: 💡 add options to query for all targets ✅ Closes: https://hashicorp.atlassian.net/browse/ICU-11802 * refactor: 💡 handle ESC key press for dropdown ✅ Closes: https://hashicorp.atlassian.net/browse/ICU-11802 * feat: 🎸 add searching within scope dropdown list ✅ Closes: https://hashicorp.atlassian.net/browse/ICU-11802 * refactor: 💡 set pushToStore to false for all target query ✅ Closes: https://hashicorp.atlassian.net/browse/ICU-11802 * refactor: 💡 handle scopes not having names ✅ Closes: https://hashicorp.atlassian.net/browse/ICU-11802 * refactor: 💡 move fetch allTargets to controller ✅ Closes: https://hashicorp.atlassian.net/browse/ICU-11802 * refactor: 💡 fix rebase conflicts ✅ Closes: https://hashicorp.atlassian.net/browse/ICU-11802 * test: 💍 fix test ✅ Closes: https://hashicorp.atlassian.net/browse/ICU-11802 * refactor: 💡 update allTargets query to have empty query ✅ Closes: https://hashicorp.atlassian.net/browse/ICU-11802 * refactor: 💡 change let to const ✅ Closes: https://hashicorp.atlassian.net/browse/ICU-11802 * refactor: 💡 add replace: true to scopes query param ✅ Closes: https://hashicorp.atlassian.net/browse/ICU-11802 * refactor: 💡 add allTargets fetch back to model hook ✅ Closes: https://hashicorp.atlassian.net/browse/ICU-11802 * refactor: 💡 change on search to on input to trigger action ✅ Closes: https://hashicorp.atlassian.net/browse/ICU-11802 * test: 💍 fix test by adding more target returns to stub ✅ Closes: https://hashicorp.atlassian.net/browse/ICU-11802 * feat: 🎸 create filterable-dropdown rose component ✅ Closes: https://hashicorp.atlassian.net/browse/ICU-11802 * feat: 🎸 finish Rose::FilterableDropdown component ✅ Closes: https://hashicorp.atlassian.net/browse/ICU-11802 * feat: 🎸 use FilterableDropdown in targets table ✅ Closes: https://hashicorp.atlassian.net/browse/ICU-11802 * refactor: 🤖 move methods comment ✅ Closes: https://hashicorp.atlassian.net/browse/ICU-11802 * refactor: 💡 render Rose::FilterableDropdown correctly in test ✅ Closes: https://hashicorp.atlassian.net/browse/ICU-11802 * test: 💍 remove generated comments from tests ✅ Closes: https://hashicorp.atlassian.net/browse/ICU-11802 * refactor: 💡 improve readability of itemOptions getter ✅ Closes: https://hashicorp.atlassian.net/browse/ICU-11802 * refactor: 💡 change block parameters to uppercase ✅ Closes: https://hashicorp.atlassian.net/browse/ICU-11802 * test: 💍 skip generated tests until adding more ✅ Closes: https://hashicorp.atlassian.net/browse/ICU-11802 --- .../rose/filterable-dropdown/index.hbs | 51 ++++++++++++ .../rose/filterable-dropdown/index.js | 83 +++++++++++++++++++ .../rose/filterable-dropdown/index.js | 1 + .../rose/filterable-dropdown/index-test.js | 25 ++++++ .../scopes/scope/projects/targets/index.js | 30 ++++++- .../routes/scopes/scope/projects/sessions.js | 2 +- .../scopes/scope/projects/targets/index.js | 29 +++++-- .../scopes/scope/projects/targets/index.hbs | 52 +++++++----- .../acceptance/projects/targets/index-test.js | 10 +-- .../projects/targets/target-test.js | 2 +- ui/desktop/tests/acceptance/scopes-test.js | 15 +++- 11 files changed, 263 insertions(+), 37 deletions(-) create mode 100644 addons/rose/addon/components/rose/filterable-dropdown/index.hbs create mode 100644 addons/rose/addon/components/rose/filterable-dropdown/index.js create mode 100644 addons/rose/app/components/rose/filterable-dropdown/index.js create mode 100644 addons/rose/tests/integration/components/rose/filterable-dropdown/index-test.js 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);