Sync Destinations List Name Filter Updates (#24695)

* updates destination name filter to use FilterInput component

* simplifies destinations list redirect condition

* fixes issue with sync destination type filter and issue filtering by both name and type

* unsets page query param in sync destination secrets route
This commit is contained in:
Jordan Reimer 2024-01-05 16:41:57 -07:00 committed by GitHub
parent 87ab7497fa
commit 3153673894
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 96 additions and 28 deletions

View File

@ -110,6 +110,9 @@
}
}
.opacity-050 {
opacity: 0.5;
}
.opacity-060 {
opacity: 0.6;
}

View File

@ -6,7 +6,7 @@
<div>
<div class="field">
<p class="control has-icons-left has-icons-right">
<span class="input has-text-grey-light">{{or @placeholder "Search"}}</span>
<span class="input opacity-050">{{or @placeholder "Search"}}</span>
<Icon @name="search" class="search-icon has-text-grey-light" />
</p>
</div>

View File

@ -28,18 +28,17 @@
class="is-marginless"
data-test-filter="type"
/>
<SearchSelect
@options={{this.destinationNames}}
@objectKeys={{array "id" "name"}}
@passObject={{true}}
@selectLimit={{1}}
@disallowNewItems={{true}}
@placeholder="Filter by name"
@inputValue={{if @nameFilter (array @nameFilter)}}
@onChange={{fn this.onFilterChange "name"}}
class="is-marginless has-left-padding-s"
data-test-filter="name"
/>
<div class="has-left-margin-s">
<FilterInput
id="name-filter"
aria-label="Filter by name"
placeholder="Filter by name"
value={{@nameFilter}}
data-test-filter="name"
@autofocus={{true}}
@onInput={{fn this.onFilterChange "name"}}
/>
</div>
</ToolbarFilters>
<ToolbarActions>
<ToolbarLink @route="secrets.destinations.create" @type="add" data-test-create-destination>

View File

@ -9,6 +9,7 @@ import { action } from '@ember/object';
import { getOwner } from '@ember/application';
import errorMessage from 'vault/utils/error-message';
import { findDestination, syncDestinations } from 'core/helpers/sync-destinations';
import { next } from '@ember/runloop';
import type SyncDestinationModel from 'vault/vault/models/sync/destination';
import type RouterService from '@ember/routing/router-service';
@ -16,6 +17,7 @@ import type StoreService from 'vault/services/store';
import type FlashMessageService from 'vault/services/flash-messages';
import type { EngineOwner } from 'vault/vault/app-types';
import type { SyncDestinationName, SyncDestinationType } from 'vault/vault/helpers/sync-destinations';
import type Transition from '@ember/routing/transition';
interface Args {
destinations: Array<SyncDestinationModel>;
@ -28,15 +30,31 @@ export default class SyncSecretsDestinationsPageComponent extends Component<Args
@service declare readonly store: StoreService;
@service declare readonly flashMessages: FlashMessageService;
// for some reason there isn't a full page refresh happening when transitioning on filter change
// when the transition happens it causes the FilterInput component to lose focus since it can only focus on didInsert
// to work around this, verify that a transition from this route was completed and then focus the input
constructor(owner: unknown, args: Args) {
super(owner, args);
this.router.on('routeDidChange', this.focusNameFilter);
}
willDestroy(): void {
super.willDestroy();
this.router.off('routeDidChange', this.focusNameFilter);
}
focusNameFilter(transition?: Transition) {
const route = 'vault.cluster.sync.secrets.destinations.index';
if (transition?.from?.name === route && transition?.to?.name === route) {
next(() => document.getElementById('name-filter')?.focus());
}
}
// typeFilter arg comes in as destination type but we need to pass the destination display name into the SearchSelect
get typeFilterName() {
return findDestination(this.args.typeFilter)?.name;
}
get destinationNames() {
return this.args.destinations.map((destination) => ({ id: destination.name, name: destination.name }));
}
get destinationTypes() {
return syncDestinations().map((d) => ({ id: d.name, name: d.type }));
}
@ -65,9 +83,10 @@ export default class SyncSecretsDestinationsPageComponent extends Component<Args
}
@action
onFilterChange(key: string, selectObject: Array<{ id: string; name: string } | undefined>) {
onFilterChange(key: string, value: { id: string; name: string }[] | string | undefined) {
const queryValue = Array.isArray(value) ? value[0]?.name : value;
this.router.transitionTo('vault.cluster.sync.secrets.destinations', {
queryParams: { [key]: selectObject[0]?.name },
queryParams: { [key]: queryValue },
});
}

View File

@ -59,7 +59,7 @@ export default class DestinationsCreateForm extends Component<Args> {
@waitFor
*save(event: Event) {
event.preventDefault();
this.error = '';
// clear out validation warnings
this.modelValidations = null;
const { destination } = this.args;

View File

@ -8,12 +8,24 @@ import { inject as service } from '@ember/service';
import { hash } from 'rsvp';
import type StoreService from 'vault/services/store';
import SyncDestinationModel from 'vault/vault/models/sync/destination';
import type SyncDestinationModel from 'vault/vault/models/sync/destination';
import type SyncAssociationModel from 'vault/vault/models/sync/association';
import type Controller from '@ember/controller';
interface SyncDestinationSecretsRouteParams {
page: string;
}
interface SyncDestinationSecretsRouteModel {
destination: SyncDestinationModel;
associations: SyncAssociationModel[];
}
interface SyncDestinationSecretsController extends Controller {
model: SyncDestinationSecretsRouteModel;
page: number | undefined;
}
export default class SyncDestinationSecretsRoute extends Route {
@service declare readonly store: StoreService;
@ -35,4 +47,10 @@ export default class SyncDestinationSecretsRoute extends Route {
}),
});
}
resetController(controller: SyncDestinationSecretsController, isExiting: boolean) {
if (isExiting) {
controller.set('page', undefined);
}
}
}

View File

@ -11,6 +11,7 @@ import type StoreService from 'vault/services/store';
import type RouterService from '@ember/routing/router-service';
import type { ModelFrom } from 'vault/vault/route';
import type SyncDestinationModel from 'vault/vault/models/sync/destination';
import type Controller from '@ember/controller';
interface SyncSecretsDestinationsIndexRouteParams {
name: string;
@ -18,6 +19,19 @@ interface SyncSecretsDestinationsIndexRouteParams {
page: string;
}
interface SyncSecretsDestinationsRouteModel {
destinations: SyncDestinationModel[];
nameFilter: string | undefined;
typeFilter: string | undefined;
}
interface SyncSecretsDestinationsController extends Controller {
model: SyncSecretsDestinationsRouteModel;
page: number | undefined;
name: number | undefined;
type: number | undefined;
}
export default class SyncSecretsDestinationsIndexRoute extends Route {
@service declare readonly store: StoreService;
@service declare readonly router: RouterService;
@ -35,7 +49,7 @@ export default class SyncSecretsDestinationsIndexRoute extends Route {
};
redirect(model: ModelFrom<SyncSecretsDestinationsIndexRoute>) {
if (model.destinations.length === 0) {
if (!model.destinations.meta.total) {
this.router.transitionTo('vault.cluster.sync.secrets.overview');
}
}
@ -43,7 +57,7 @@ export default class SyncSecretsDestinationsIndexRoute extends Route {
filterData(dataset: Array<SyncDestinationModel>, name: string, type: string): Array<SyncDestinationModel> {
let filteredDataset = dataset;
const filter = (key: keyof SyncDestinationModel, value: string) => {
return dataset.filter((model) => {
return filteredDataset.filter((model) => {
return model[key].toLowerCase().includes(value.toLowerCase());
});
};
@ -68,4 +82,14 @@ export default class SyncSecretsDestinationsIndexRoute extends Route {
typeFilter: params.type,
});
}
resetController(controller: SyncSecretsDestinationsController, isExiting: boolean) {
if (isExiting) {
controller.setProperties({
page: undefined,
name: undefined,
type: undefined,
});
}
}
}

View File

@ -9,7 +9,7 @@ import { setupMirage } from 'ember-cli-mirage/test-support';
import syncScenario from 'vault/mirage/scenarios/sync';
import syncHandlers from 'vault/mirage/handlers/sync';
import authPage from 'vault/tests/pages/auth';
import { click, visit } from '@ember/test-helpers';
import { click, visit, fillIn } from '@ember/test-helpers';
import { PAGE } from 'vault/tests/helpers/sync/sync-selectors';
const { searchSelect, filter, listItem } = PAGE;
@ -29,6 +29,11 @@ module('Acceptance | sync | destinations', function (hooks) {
assert.dom(listItem).exists({ count: 6 }, 'All destinations render');
await click(`${filter('type')} .ember-basic-dropdown-trigger`);
await click(searchSelect.option());
assert.dom(listItem).exists({ count: 2 }, 'Filtered destinations render');
assert.dom(listItem).exists({ count: 2 }, 'Destinations are filtered by type');
await fillIn(filter('name'), 'new');
assert.dom(listItem).exists({ count: 1 }, 'Destinations are filtered by type and name');
await click(searchSelect.removeSelected);
await fillIn(filter('name'), 'gcp');
assert.dom(listItem).exists({ count: 1 }, 'Destinations are filtered by name');
});
});

View File

@ -14,6 +14,7 @@ export const SELECTORS = {
icon: (name) => `[data-test-icon="${name}"]`,
tab: (name) => `[data-test-tab="${name}"]`,
filter: (name) => `[data-test-filter="${name}"]`,
filterInput: '[data-test-filter-input]',
confirmModalInput: '[data-test-confirmation-modal-input]',
confirmButton: '[data-test-confirm-button]',
emptyStateTitle: '[data-test-empty-state-title]',

View File

@ -7,7 +7,7 @@ import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { setupEngine } from 'ember-engines/test-support';
import { setupMirage } from 'ember-cli-mirage/test-support';
import { render, click } from '@ember/test-helpers';
import { render, click, fillIn } from '@ember/test-helpers';
import hbs from 'htmlbars-inline-precompile';
import { allowAllCapabilitiesStub } from 'vault/tests/helpers/stubs';
import sinon from 'sinon';
@ -97,8 +97,7 @@ module('Integration | Component | sync | Page::Destinations', function (hooks) {
);
// NAME FILTER
await click(`${filter('name')} .ember-basic-dropdown-trigger`);
await click(searchSelect.option(searchSelect.optionIndex('destination-aws')));
await fillIn(filter('name'), 'destination-aws');
assert.deepEqual(
this.transitionStub.lastCall.args,
['vault.cluster.sync.secrets.destinations', { queryParams: { name: 'destination-aws' } }],