kibana/test/functional/services/saved_query_management_component.ts
Davis McPhee 509248b0c6
[Saved Queries] Improve saved query management (#170599)
## Summary

This PR introduces a number of changes and improvements to saved query
management:
- Add server side pagination (5 queries per page) and search
functionality to the "Load query" list, which improves UX and
performance by no longer requesting all queries at once.
- Redesign the "Load query" list to improve the UX and a11y, making it
possible for keyboard users to effectively navigate the list and
load/delete queries.
- Add an "Active" badge to the "Load query" list to indicate which list
entry represents the currently loaded query, and hoist the entry to the
top of the first page for better visibility when no search term exists.
- Deprecate the saved query `/_all` endpoint and update it to return
only the first 100 queries instead of loading them all into memory at
once.
- Add a new `titleKeyword` field to the saved query SO, which allows
sorting queries alphabetically by title when displaying them in the
"Load query" list.
- Improve the performance of the "has saved queries" check when Unified
Search is mounted to no longer request actual queries, and instead just
request the count.
- Update the saved query duplicate title check to no longer rely on
fetching all queries at once, and instead asynchronously check for
duplicates by title on save.
- Add server side duplicate title validation to the create and update
saved query endpoints.
- Various small fixes and cleanups throughout saved query management.


43328aea-0f7b-4b7a-a5fb-e33ed822f317

Resolves #172044.
Resolves #176427.

## Testing

To generate saved queries for testing, run the script below and replace
`{KIBANA_REQUEST_COOKIE}` with the cookie header value from an API
request of a Kibana user with an active session:
```shell
for i in {1..100}; do curl 'http://localhost:5601/internal/saved_query/_create' \
  -H 'Accept: */*' \
  -H 'Accept-Language: en-US,en;q=0.9,az;q=0.8,es;q=0.7' \
  -H 'Cache-Control: no-cache' \
  -H 'Connection: keep-alive' \
  -H 'Content-Type: application/json' \
  -H 'Cookie: {KIBANA_REQUEST_COOKIE}' \
  -H 'elastic-api-version: 1' \
  -H 'kbn-build-number: 9007199254740991' \
  -H 'kbn-version: 8.13.0' \
  -H 'x-elastic-internal-origin: Kibana' \
  --data-raw '{"title":"query '"$(echo $(($i - 1)) | tr '[0-9]' '[a-j]')"'","description":"","query":{"query":"bytes > 500","language":"kuery"},"filters":[]}' \
  --compressed; done
```

### Checklist

- [x] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [ ]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [x] Any UI touched in this PR is usable by keyboard only (learn more
about [keyboard accessibility](https://webaim.org/techniques/keyboard/))
- [x] Any UI touched in this PR does not create any new axe failures
(run axe in browser:
[FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/),
[Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US))
- [ ] If a plugin configuration key changed, check if it needs to be
allowlisted in the cloud and added to the [docker
list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker)
- [x] This renders correctly on smaller devices using a responsive
layout. (You can test this [in your
browser](https://www.browserstack.com/guide/responsive-testing-on-local-server))
- [x] This was checked for [cross-browser
compatibility](https://www.elastic.co/support/matrix#matrix_browsers)

### For maintainers

- [ ] This was checked for breaking API changes and was [labeled
appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Stratoula Kalafateli <efstratia.kalafateli@elastic.co>
2024-02-12 13:18:17 -04:00

238 lines
8.6 KiB
TypeScript

/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import expect from '@kbn/expect';
import { FtrService } from '../ftr_provider_context';
export class SavedQueryManagementComponentService extends FtrService {
private readonly testSubjects = this.ctx.getService('testSubjects');
private readonly queryBar = this.ctx.getService('queryBar');
private readonly retry = this.ctx.getService('retry');
private readonly config = this.ctx.getService('config');
private readonly common = this.ctx.getPageObject('common');
public async getCurrentlyLoadedQueryID() {
await this.openSavedQueryManagementComponent();
try {
return await this.testSubjects.getVisibleText('savedQueryTitle');
} catch {
return undefined;
}
}
public async saveNewQuery(
name: string,
description: string,
includeFilters: boolean,
includeTimeFilter: boolean
) {
await this.openSaveCurrentQueryModal();
await this.submitSaveQueryForm(name, description, includeFilters, includeTimeFilter);
}
public async saveNewQueryWithNameError(name?: string) {
await this.openSaveCurrentQueryModal();
if (name) {
await this.testSubjects.setValue('saveQueryFormTitle', name);
}
// Form input validation only happens onBlur. Clicking the save button should de-focus the
// input element and the validation should prevent a save from actually happening if there's
// an error.
await this.testSubjects.click('savedQueryFormSaveButton');
await this.retry.waitForWithTimeout('save button to be disabled', 1000, async () => {
const saveQueryFormSaveButtonStatus = await this.testSubjects.isEnabled(
'savedQueryFormSaveButton'
);
return saveQueryFormSaveButtonStatus === false;
});
const contextMenuPanelTitleButton = await this.testSubjects.exists(
'contextMenuPanelTitleButton'
);
if (contextMenuPanelTitleButton) {
await this.testSubjects.click('contextMenuPanelTitleButton');
}
}
public async saveCurrentlyLoadedAsNewQuery(
name: string,
description: string,
includeFilters: boolean,
includeTimeFilter: boolean
) {
await this.openSavedQueryManagementComponent();
await this.testSubjects.click('saved-query-management-save-button');
await this.submitSaveQueryForm(name, description, includeFilters, includeTimeFilter);
}
public async updateCurrentlyLoadedQuery(
description: string,
includeFilters: boolean,
includeTimeFilter: boolean
) {
await this.openSavedQueryManagementComponent();
await this.testSubjects.click('saved-query-management-save-changes-button');
await this.submitSaveQueryForm(null, description, includeFilters, includeTimeFilter);
}
public async loadSavedQuery(title: string) {
await this.openSavedQueryManagementComponent();
await this.testSubjects.click('saved-query-management-load-button');
await this.testSubjects.click(`~load-saved-query-${title}-button`);
await this.testSubjects.click('saved-query-management-apply-changes-button');
await this.retry.try(async () => {
await this.testSubjects.missingOrFail('queryBarMenuPanel');
});
await this.retry.try(async () => {
await this.openSavedQueryManagementComponent();
const selectedSavedQueryText = await this.testSubjects.getVisibleText('savedQueryTitle');
expect(selectedSavedQueryText).to.eql(title);
});
await this.closeSavedQueryManagementComponent();
}
public async deleteSavedQuery(title: string) {
await this.openSavedQueryManagementComponent();
const shouldClickLoadMenu = await this.testSubjects.exists(
'saved-query-management-load-button'
);
if (shouldClickLoadMenu) {
await this.testSubjects.click('saved-query-management-load-button');
}
await this.testSubjects.click(`~load-saved-query-${title}-button`);
await this.retry.waitFor('delete saved query', async () => {
await this.testSubjects.click(`delete-saved-query-button`);
const exists = await this.testSubjects.exists('confirmModalTitleText');
return exists === true;
});
await this.common.clickConfirmOnModal();
}
async clearCurrentlyLoadedQuery() {
await this.openSavedQueryManagementComponent();
await this.testSubjects.click('filter-sets-removeAllFilters');
await this.closeSavedQueryManagementComponent();
const queryString = await this.queryBar.getQueryString();
expect(queryString).to.be.empty();
}
async submitSaveQueryForm(
title: string | null,
description: string,
includeFilters: boolean,
includeTimeFilter: boolean
) {
if (title) {
await this.testSubjects.setValue('saveQueryFormTitle', title);
}
const currentIncludeFiltersValue =
(await this.testSubjects.getAttribute(
'saveQueryFormIncludeFiltersOption',
'aria-checked'
)) === 'true';
if (currentIncludeFiltersValue !== includeFilters) {
await this.testSubjects.click('saveQueryFormIncludeFiltersOption');
}
const currentIncludeTimeFilterValue =
(await this.testSubjects.getAttribute(
'saveQueryFormIncludeTimeFilterOption',
'aria-checked'
)) === 'true';
if (currentIncludeTimeFilterValue !== includeTimeFilter) {
await this.testSubjects.click('saveQueryFormIncludeTimeFilterOption');
}
await this.testSubjects.click('savedQueryFormSaveButton');
await this.retry.try(async () => {
await this.testSubjects.missingOrFail('saveQueryForm');
});
}
async savedQueryExist(title: string) {
await this.openSavedQueryManagementComponent();
await this.testSubjects.click('saved-query-management-load-button');
const exists = await this.testSubjects.exists(`~load-saved-query-${title}-button`);
await this.closeSavedQueryManagementComponent();
return exists;
}
async savedQueryExistOrFail(title: string) {
await this.retry.waitFor('load saved query', async () => {
await this.openSavedQueryManagementComponent();
const shouldClickLoadMenu = await this.testSubjects.exists(
'saved-query-management-load-button'
);
return shouldClickLoadMenu === true;
});
await this.testSubjects.click('saved-query-management-load-button');
await this.testSubjects.existOrFail(`~load-saved-query-${title}-button`);
}
async savedQueryTextExist(text: string) {
await this.openSavedQueryManagementComponent();
const queryString = await this.queryBar.getQueryString();
expect(queryString).to.eql(text);
}
async savedQueryMissingOrFail(title: string) {
await this.retry.try(async () => {
await this.openSavedQueryManagementComponent();
await this.testSubjects.missingOrFail(`~load-saved-query-${title}-button`);
});
await this.closeSavedQueryManagementComponent();
}
async openSavedQueryManagementComponent() {
const isOpenAlready = await this.testSubjects.exists('queryBarMenuPanel');
if (isOpenAlready) return;
await this.testSubjects.click('showQueryBarMenu');
}
async closeSavedQueryManagementComponent() {
const isOpenAlready = await this.testSubjects.exists('queryBarMenuPanel');
if (!isOpenAlready) return;
await this.retry.try(async () => {
await this.testSubjects.click('showQueryBarMenu');
await this.testSubjects.missingOrFail('queryBarMenuPanel');
});
}
async openSaveCurrentQueryModal() {
await this.openSavedQueryManagementComponent();
await this.retry.try(async () => {
await this.testSubjects.click('saved-query-management-save-button');
await this.testSubjects.existOrFail('saveQueryForm', {
timeout: this.config.get('timeouts.waitForExists'),
});
});
}
async saveNewQueryMissingOrFail() {
await this.openSavedQueryManagementComponent();
const saveFilterSetBtn = await this.testSubjects.find('saved-query-management-save-button');
const isDisabled = await saveFilterSetBtn.getAttribute('disabled');
expect(isDisabled).to.equal('true');
}
async updateCurrentlyLoadedQueryMissingOrFail() {
await this.openSavedQueryManagementComponent();
await this.testSubjects.missingOrFail('saved-query-management-save-changes-button');
}
async deleteSavedQueryMissingOrFail(title: string) {
await this.openSavedQueryManagementComponent();
await this.testSubjects.missingOrFail(`delete-saved-query-${title}-button`);
}
}