Icu 11802 filter targets by project scope (#2022)

* 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
This commit is contained in:
Cameron Perera authored and Zhihe Li committed 2024-01-16 10:17:46 -05:00
1 parent 15d815c271
commit 7af8e71a2d
11 files changed
+263 -37

No files matched your search

@@ -0,0 +1,51 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: MPL-2.0
}}
<Hds::Dropdown @onClose={{this.close}} as |DD|>
<DD.ToggleButton
@color='secondary'
@text={{@name}}
@count={{@checkedItems.length}}
/>
{{#if @isSearchable}}
<DD.Header @hasDivider={{true}}>
<Hds::Form::TextInput::Base
@type='search'
@value={{this.searchTerm}}
placeholder={{t 'actions.narrow-results'}}
aria-label={{t 'actions.narrow-results'}}
{{on 'input' this.filterItems}}
data-test-dropdown-search
/>
</DD.Header>
{{/if}}
{{#unless this.itemOptions}}
<DD.Description @text={{t 'titles.no-results'}} />
{{/unless}}
{{#if (has-block)}}
{{yield DD this.selectItem this.itemOptions}}
{{else}}
{{#each this.itemOptions as |itemOption|}}
<DD.Checkbox
@value={{itemOption.id}}
data-test-checkbox={{itemOption.id}}
checked={{includes itemOption.id @checkedItems}}
{{on 'change' this.selectItem}}
>
{{itemOption.displayName}}
</DD.Checkbox>
{{/each}}
{{/if}}
<DD.Footer @hasDivider={{true}}>
<Hds::Button
@text='Apply'
@isFullWidth={{true}}
@size='small'
{{on 'click' (fn this.applyFilter DD.close)}}
/>
</DD.Footer>
</Hds::Dropdown>
@@ -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 = '';
}
}
@@ -0,0 +1 @@
export { default } from 'rose/components/rose/filterable-dropdown/index';
@@ -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`<Rose::FilterableDropdown />`);
assert.dom(this.element).hasText('');
await render(hbs`
<Rose::FilterableDropdown>
template block text
</Rose::FilterableDropdown>
`);
assert.dom(this.element).hasText('template block text');
});
},
);
@@ -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];
}
}
@@ -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');
}
}
@@ -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 };
}
}
@@ -22,25 +22,35 @@
aria-label={{t 'actions.search'}}
{{on 'input' this.handleSearchInput}}
/>
<S.Dropdown as |DD|>
<DD.ToggleButton
@color='secondary'
@text={{t 'resources.scope.title'}}
/>
<DD.Header @hasDivider={{true}}>
<Hds::Form::TextInput::Base
@type='search'
placeholder={{t 'actions.narrow-results'}}
aria-label={{t 'actions.narrow-results'}}
data-test-search
/>
</DD.Header>
{{#each this.searchItems as |item|}}
<DD.Checkbox @value={{item}} data-test-checkbox={{item}}>
{{item}}
</DD.Checkbox>
{{/each}}
</S.Dropdown>
<S.Generic>
<Rose::FilterableDropdown
@name={{t 'resources.scope.title'}}
@itemOptions={{this.availableScopes}}
@checkedItems={{this.scopes}}
@applyFilter={{this.applyFilter}}
@isSearchable={{true}}
as |FD selectItem itemOptions|
>
{{#let (group-by itemOptions 'scopeModel') as |orgs|}}
{{#each orgs as |org|}}
<FD.Generic>
<FlightIcon @name='org' />
{{org.key.displayName}}
</FD.Generic>
{{#each org.items as |project|}}
<FD.Checkbox
@value={{project.id}}
data-test-checkbox={{project.name}}
checked={{includes project.id this.scopes}}
{{on 'change' selectItem}}
>
{{project.displayName}}
</FD.Checkbox>
{{/each}}
{{/each}}
{{/let}}
</Rose::FilterableDropdown>
</S.Generic>
<S.Dropdown as |DD|>
<DD.ToggleButton @color='secondary' @text='Active session' />
<DD.Checkbox>{{t 'actions.yes'}}</DD.Checkbox>
@@ -111,7 +121,9 @@
<code>{{B.data.project.id}}</code>
</Copyable>
</div>
<Hds::Badge @color='neutral' @text={{B.data.project.name}} />
{{#if B.data.project.name}}
<Hds::Badge @color='neutral' @text={{B.data.project.name}} />
{{/if}}
</B.Td>
<B.Td>
{{#if (can 'connect target' B.data)}}
@@ -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;
@@ -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) {
+12 -3
View File
@@ -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);