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:
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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in new issue
Block a user