diff --git a/packages/content-management/table_list/src/actions.ts b/packages/content-management/table_list/src/actions.ts index ba706025b036..6ae2740381fc 100644 --- a/packages/content-management/table_list/src/actions.ts +++ b/packages/content-management/table_list/src/actions.ts @@ -6,9 +6,9 @@ * Side Public License, v 1. */ import type { IHttpFetchError } from '@kbn/core-http-browser'; -import type { CriteriaWithPagination, Direction, Query } from '@elastic/eui'; +import type { Query } from '@elastic/eui'; -import type { SortColumnField } from './components'; +import type { State, UserContentCommonSchema } from './table_list_view'; /** Action to trigger a fetch of the table items */ export interface OnFetchItemsAction { @@ -49,17 +49,14 @@ export interface OnSelectionChangeAction { } /** Action to update the state of the table whenever the sort or page size changes */ -export interface OnTableChangeAction { +export interface OnTableChangeAction { type: 'onTableChange'; - data: CriteriaWithPagination; -} - -/** Action to update the sort column of the table */ -export interface OnTableSortChangeAction { - type: 'onTableSortChange'; data: { - field: SortColumnField; - direction: Direction; + sort?: State['tableSort']; + page?: { + pageIndex: number; + pageSize: number; + }; }; } @@ -77,13 +74,12 @@ export interface OnSearchQueryChangeAction { }; } -export type Action = +export type Action = | OnFetchItemsAction | OnFetchItemsSuccessAction | OnFetchItemsErrorAction | DeleteItemsActions | OnSelectionChangeAction | OnTableChangeAction - | OnTableSortChangeAction | ShowConfirmDeleteItemsModalAction | OnSearchQueryChangeAction; diff --git a/packages/content-management/table_list/src/components/table.tsx b/packages/content-management/table_list/src/components/table.tsx index 1e4ee84204dd..330eb67be427 100644 --- a/packages/content-management/table_list/src/components/table.tsx +++ b/packages/content-management/table_list/src/components/table.tsx @@ -17,7 +17,6 @@ import { SearchFilterConfig, Direction, Query, - Ast, } from '@elastic/eui'; import { useServices } from '../services'; @@ -54,6 +53,7 @@ interface Props extends State, TagManageme deleteItems: TableListViewProps['deleteItems']; onSortChange: (column: SortColumnField, direction: Direction) => void; onTableChange: (criteria: CriteriaWithPagination) => void; + onTableSearchChange: (arg: { query: Query | null; queryText: string }) => void; clearTagSelection: () => void; } @@ -73,6 +73,7 @@ export function Table({ deleteItems, tableCaption, onTableChange, + onTableSearchChange, onSortChange, addOrRemoveExcludeTagFilter, addOrRemoveIncludeTagFilter, @@ -128,19 +129,6 @@ export function Table({ addOrRemoveIncludeTagFilter, }); - const onSearchQueryChange = useCallback( - (arg: { query: Query | null; queryText: string }) => { - dispatch({ - type: 'onSearchQueryChange', - data: { - query: arg.query ?? new Query(Ast.create([]), undefined, arg.queryText), - text: arg.queryText, - }, - }); - }, - [dispatch] - ); - const tableSortSelectFilter = useMemo(() => { return { type: 'custom_component', @@ -191,7 +179,7 @@ export function Table({ const search = useMemo(() => { return { - onChange: onSearchQueryChange, + onChange: onTableSearchChange, toolsLeft: renderToolsLeft(), query: searchQuery.query ?? undefined, box: { @@ -200,7 +188,7 @@ export function Table({ }, filters: searchFilters, }; - }, [onSearchQueryChange, renderToolsLeft, searchFilters, searchQuery.query]); + }, [onTableSearchChange, renderToolsLeft, searchFilters, searchQuery.query]); const noItemsMessage = ( () { + let sortColumnChanged = false; + return (state: State, action: Action): State => { switch (action.type) { case 'onFetchItems': { @@ -26,7 +28,10 @@ export function getReducer() { // We only get the state on the initial fetch of items // After that we don't want to reset the columns or change the sort after fetching hasUpdatedAtMetadata = Boolean(items.find((item) => Boolean(item.updatedAt))); - if (hasUpdatedAtMetadata) { + + // Only change the table sort if it hasn't been changed already. + // For example if its state comes from the URL, we don't want to override it here. + if (hasUpdatedAtMetadata && !sortColumnChanged) { tableSort = { field: 'updatedAt' as const, direction: 'desc' as const, @@ -58,6 +63,10 @@ export function getReducer() { }; } case 'onSearchQueryChange': { + if (action.data.text === state.searchQuery.text) { + return state; + } + return { ...state, searchQuery: action.data, @@ -65,23 +74,24 @@ export function getReducer() { }; } case 'onTableChange': { - const tableSort = (action.data.sort as State['tableSort']) ?? state.tableSort; + if (action.data.sort) { + sortColumnChanged = true; + } + + const tableSort = action.data.sort ?? state.tableSort; + const pageIndex = action.data.page?.pageIndex ?? state.pagination.pageIndex; + const pageSize = action.data.page?.pageSize ?? state.pagination.pageSize; + return { ...state, pagination: { ...state.pagination, - pageIndex: action.data.page.index, - pageSize: action.data.page.size, + pageIndex, + pageSize, }, tableSort, }; } - case 'onTableSortChange': { - return { - ...state, - tableSort: action.data, - }; - } case 'showConfirmDeleteItemsModal': { return { ...state, diff --git a/packages/content-management/table_list/src/services.tsx b/packages/content-management/table_list/src/services.tsx index a328641ad031..5edc16d9a915 100644 --- a/packages/content-management/table_list/src/services.tsx +++ b/packages/content-management/table_list/src/services.tsx @@ -47,11 +47,11 @@ export interface Services { notifyError: NotifyFn; currentAppId$: Observable; navigateToUrl: (url: string) => Promise | void; - searchQueryParser?: (searchQuery: string) => { + searchQueryParser?: (searchQuery: string) => Promise<{ searchQuery: string; references?: SavedObjectsFindOptionsReference[]; referencesToExclude?: SavedObjectsFindOptionsReference[]; - }; + }>; DateFormatterComp?: DateFormatter; /** Handler to retrieve the list of available tags */ getTagList: () => Tag[]; @@ -142,12 +142,12 @@ export interface TableListViewKibanaDependencies { useName?: boolean; tagField?: string; } - ) => { + ) => Promise<{ searchTerm: string; tagReferences: SavedObjectsFindOptionsReference[]; tagReferencesToExclude: SavedObjectsFindOptionsReference[]; valid: boolean; - }; + }>; getTagList: () => Tag[]; getTagIdsFromReferences: (references: SavedObjectsReference[]) => string[]; }; @@ -167,8 +167,8 @@ export const TableListViewKibanaProvider: FC = const searchQueryParser = useMemo(() => { if (savedObjectsTagging) { - return (searchQuery: string) => { - const res = savedObjectsTagging.ui.parseSearchQuery(searchQuery, { useName: true }); + return async (searchQuery: string) => { + const res = await savedObjectsTagging.ui.parseSearchQuery(searchQuery, { useName: true }); return { searchQuery: res.searchTerm, references: res.tagReferences, diff --git a/packages/content-management/table_list/src/table_list_view.test.tsx b/packages/content-management/table_list/src/table_list_view.test.tsx index 40893295fe34..92b03566cdc6 100644 --- a/packages/content-management/table_list/src/table_list_view.test.tsx +++ b/packages/content-management/table_list/src/table_list_view.test.tsx @@ -49,6 +49,7 @@ const requiredProps: TableListViewProps = { tableListTitle: 'test title', findItems: jest.fn().mockResolvedValue({ total: 0, hits: [] }), getDetailViewLink: () => 'http://elastic.co', + urlStateEnabled: false, }; // FLAKY: https://github.com/elastic/kibana/issues/145267 @@ -66,7 +67,7 @@ describe.skip('TableListView', () => { WithServices(TableListView), { defaultProps: { ...requiredProps }, - memoryRouter: { wrapComponent: false }, + memoryRouter: { wrapComponent: true }, } ); @@ -333,7 +334,7 @@ describe.skip('TableListView', () => { WithServices(TableListView, { TagList: getTagList({ references: [] }) }), { defaultProps: { ...requiredProps }, - memoryRouter: { wrapComponent: false }, + memoryRouter: { wrapComponent: true }, } ); @@ -544,7 +545,7 @@ describe.skip('TableListView', () => { WithServices(TableListView), { defaultProps: { ...requiredProps }, - memoryRouter: { wrapComponent: false }, + memoryRouter: { wrapComponent: true }, } ); @@ -602,7 +603,7 @@ describe.skip('TableListView', () => { }), { defaultProps: { ...requiredProps }, - memoryRouter: { wrapComponent: false }, + memoryRouter: { wrapComponent: true }, } ); diff --git a/packages/content-management/table_list/src/table_list_view.tsx b/packages/content-management/table_list/src/table_list_view.tsx index 706c39bc7b26..9342a5076a38 100644 --- a/packages/content-management/table_list/src/table_list_view.tsx +++ b/packages/content-management/table_list/src/table_list_view.tsx @@ -38,10 +38,10 @@ import { } from './components'; import { useServices } from './services'; import type { SavedObjectsReference, SavedObjectsFindOptionsReference } from './services'; -import type { Action } from './actions'; import { getReducer } from './reducer'; import type { SortColumnField } from './components'; import { useTags } from './use_tags'; +import { useUrlState } from './use_url_state'; interface ContentEditorConfig extends Pick { @@ -59,6 +59,7 @@ export interface Props; + urlStateEnabled?: boolean; /** * Id of the heading element describing the table. This id will be used as `aria-labelledby` of the wrapper element. * If the table is not empty, this component renders its own h1 element using the same id. @@ -131,7 +132,89 @@ export interface UserContentCommonSchema { }; } -const ast = Ast.create([]); +export interface URLState { + s?: string; + sort?: { + field: SortColumnField; + direction: Direction; + }; + [key: string]: unknown; +} + +interface URLQueryParams { + s?: string; + title?: string; + sort?: string; + sortdir?: string; + [key: string]: unknown; +} + +/** + * Deserializer to convert the URL query params to a sanitized object + * we can rely on in this component. + * + * @param params The URL query params + * @returns The URLState sanitized + */ +const urlStateDeserializer = (params: URLQueryParams): URLState => { + const stateFromURL: URLState = {}; + const sanitizedParams = { ...params }; + + // If we declare 2 or more query params with the same key in the URL + // we will receive an array of value back when parsed. It is probably + // a mistake from the user so we'll sanitize the data before continuing. + ['s', 'title', 'sort', 'sortdir'].forEach((key: string) => { + if (Array.isArray(sanitizedParams[key])) { + sanitizedParams[key] = (sanitizedParams[key] as string[])[0]; + } + }); + + // For backward compability with the Dashboard app we will support both "s" and "title" passed + // in the query params. We might want to stop supporting both in a future release (v9.0?) + stateFromURL.s = sanitizedParams.s ?? sanitizedParams.title; + + if (sanitizedParams.sort === 'title' || sanitizedParams.sort === 'updatedAt') { + const field = sanitizedParams.sort === 'title' ? 'attributes.title' : 'updatedAt'; + + stateFromURL.sort = { field, direction: 'asc' }; + + if (sanitizedParams.sortdir === 'desc' || sanitizedParams.sortdir === 'asc') { + stateFromURL.sort.direction = sanitizedParams.sortdir; + } + } + + return stateFromURL; +}; + +/** + * Serializer to convert the updated state of the component into query params in the URL + * + * @param updated The updated state of our component that we want to persist in the URL + * @returns The query params (flatten object) to update the URL + */ +const urlStateSerializer = (updated: { + s?: string; + sort?: { field: 'title' | 'updatedAt'; direction: Direction }; +}) => { + const updatedQueryParams: Partial = {}; + + if (updated.sort) { + updatedQueryParams.sort = updated.sort.field; + updatedQueryParams.sortdir = updated.sort.direction; + } + + if (updated.s !== undefined) { + updatedQueryParams.s = updated.s; + updatedQueryParams.title = undefined; + } + + if (typeof updatedQueryParams.s === 'string' && updatedQueryParams.s.trim() === '') { + updatedQueryParams.s = undefined; + updatedQueryParams.title = undefined; + } + + return updatedQueryParams; +}; function TableListViewComp({ tableListTitle, @@ -142,6 +225,7 @@ function TableListViewComp({ headingId, initialPageSize, listingLimit, + urlStateEnabled = true, customTableColumn, emptyPrompt, findItems, @@ -150,7 +234,7 @@ function TableListViewComp({ deleteItems, getDetailViewLink, onClickTitle, - id = 'userContent', + id: listingId = 'userContent', contentEditor = { enabled: false }, children, titleColumnName, @@ -185,36 +269,49 @@ function TableListViewComp({ searchQueryParser, notifyError, DateFormatterComp, + getTagList, } = useServices(); + const openContentEditor = useOpenContentEditor(); + + const [urlState, setUrlState] = useUrlState({ + queryParamsDeserializer: urlStateDeserializer, + queryParamsSerializer: urlStateSerializer, + }); + const reducer = useMemo(() => { return getReducer(); }, []); - const [state, dispatch] = useReducer<(state: State, action: Action) => State>(reducer, { - items: [], - totalItems: 0, - hasInitialFetchReturned: false, - isFetchingItems: false, - isDeletingItems: false, - showDeleteModal: false, - hasUpdatedAtMetadata: false, - selectedIds: [], - searchQuery: - initialQuery !== undefined - ? { text: initialQuery, query: new Query(ast, undefined, initialQuery) } - : { text: '', query: new Query(ast, undefined, '') }, - pagination: { - pageIndex: 0, - totalItemCount: 0, - pageSize: initialPageSize, - pageSizeOptions: uniq([10, 20, 50, initialPageSize]).sort(), - }, - tableSort: { - field: 'attributes.title' as const, - direction: 'asc', - }, - }); + const initialState = useMemo>( + () => ({ + items: [], + totalItems: 0, + hasInitialFetchReturned: false, + isFetchingItems: false, + isDeletingItems: false, + showDeleteModal: false, + hasUpdatedAtMetadata: false, + selectedIds: [], + searchQuery: + initialQuery !== undefined + ? { text: initialQuery, query: new Query(Ast.create([]), undefined, initialQuery) } + : { text: '', query: new Query(Ast.create([]), undefined, '') }, + pagination: { + pageIndex: 0, + totalItemCount: 0, + pageSize: initialPageSize, + pageSizeOptions: uniq([10, 20, 50, initialPageSize]).sort(), + }, + tableSort: { + field: 'attributes.title' as const, + direction: 'asc', + }, + }), + [initialPageSize, initialQuery] + ); + + const [state, dispatch] = useReducer(reducer, initialState); const { searchQuery, @@ -247,11 +344,9 @@ function TableListViewComp({ searchQuery: searchQueryParsed, references, referencesToExclude, - } = searchQueryParser?.(searchQuery.text) ?? { - searchQuery: searchQuery.text, - references: undefined, - referencesToExclude: undefined, - }; + } = searchQueryParser + ? await searchQueryParser(searchQuery.text) + : { searchQuery: searchQuery.text, references: undefined, referencesToExclude: undefined }; const response = await findItems(searchQueryParsed, { references, referencesToExclude }); @@ -275,14 +370,20 @@ function TableListViewComp({ } }, [searchQueryParser, findItems, searchQuery.text]); - const openContentEditor = useOpenContentEditor(); + const updateQuery = useCallback( + (query: Query) => { + if (urlStateEnabled) { + setUrlState({ s: query.text }); + return; + } - const updateQuery = useCallback((query: Query) => { - dispatch({ - type: 'onSearchQueryChange', - data: { query, text: query.text }, - }); - }, []); + dispatch({ + type: 'onSearchQueryChange', + data: { query, text: query.text }, + }); + }, + [urlStateEnabled, setUrlState] + ); const { addOrRemoveIncludeTagFilter, @@ -336,7 +437,7 @@ function TableListViewComp({ render: (field: keyof T, record: T) => { return ( - id={id} + id={listingId} item={record} getDetailViewLink={getDetailViewLink} onClickTitle={onClickTitle} @@ -439,7 +540,7 @@ function TableListViewComp({ customTableColumn, hasUpdatedAtMetadata, editItem, - id, + listingId, getDetailViewLink, onClickTitle, searchQuery.text, @@ -461,16 +562,79 @@ function TableListViewComp({ // ------------ // Callbacks // ------------ - const onSortChange = useCallback((field: SortColumnField, direction: Direction) => { - dispatch({ - type: 'onTableSortChange', - data: { field, direction }, - }); - }, []); + const onTableSearchChange = useCallback( + (arg: { query: Query | null; queryText: string }) => { + const query = arg.query ?? new Query(Ast.create([]), undefined, arg.queryText); + updateQuery(query); + }, + [updateQuery] + ); - const onTableChange = useCallback((criteria: CriteriaWithPagination) => { - dispatch({ type: 'onTableChange', data: criteria }); - }, []); + const updateTableSortAndPagination = useCallback( + (data: { + sort?: State['tableSort']; + page?: { + pageIndex: number; + pageSize: number; + }; + }) => { + if (data.sort && urlStateEnabled) { + setUrlState({ + sort: { + field: data.sort.field === 'attributes.title' ? 'title' : data.sort.field, + direction: data.sort.direction, + }, + }); + } + + if (data.page || !urlStateEnabled) { + dispatch({ + type: 'onTableChange', + data, + }); + } + }, + [setUrlState, urlStateEnabled] + ); + + const onSortChange = useCallback( + (field: SortColumnField, direction: Direction) => { + updateTableSortAndPagination({ + sort: { + field, + direction, + }, + }); + }, + [updateTableSortAndPagination] + ); + + const onTableChange = useCallback( + (criteria: CriteriaWithPagination) => { + const data: { + sort?: State['tableSort']; + page?: { + pageIndex: number; + pageSize: number; + }; + } = {}; + + if (criteria.sort) { + data.sort = { + field: criteria.sort.field as SortColumnField, + direction: criteria.sort.direction, + }; + } + + data.page = { + pageIndex: criteria.page.index, + pageSize: criteria.page.size, + }; + + updateTableSortAndPagination(data); + }, + [updateTableSortAndPagination] + ); const deleteSelectedItems = useCallback(async () => { if (isDeletingItems) { @@ -573,6 +737,85 @@ function TableListViewComp({ // ------------ useDebounce(fetchItems, 300, [fetchItems]); + useEffect(() => { + if (!urlStateEnabled) { + return; + } + + // Update our Query instance based on the URL "s" text + const updateQueryFromURL = async (text: string = '') => { + let ast = Ast.create([]); + let termMatch = text; + + if (searchQueryParser) { + // Parse possible tags in the search text + const { + references, + referencesToExclude, + searchQuery: searchTerm, + } = await searchQueryParser(text); + + termMatch = searchTerm; + + if (references?.length || referencesToExclude?.length) { + const allTags = getTagList(); + + if (references?.length) { + references.forEach(({ id: refId }) => { + const tag = allTags.find(({ id }) => id === refId); + if (tag) { + ast = ast.addOrFieldValue('tag', tag.name, true, 'eq'); + } + }); + } + + if (referencesToExclude?.length) { + referencesToExclude.forEach(({ id: refId }) => { + const tag = allTags.find(({ id }) => id === refId); + if (tag) { + ast = ast.addOrFieldValue('tag', tag.name, false, 'eq'); + } + }); + } + } + } + + if (termMatch.trim() !== '') { + ast = ast.addClause({ type: 'term', value: termMatch, match: 'must' }); + } + + const updatedQuery = new Query(ast, undefined, text); + + dispatch({ + type: 'onSearchQueryChange', + data: { + query: updatedQuery, + text, + }, + }); + }; + + // Update our State "sort" based on the URL "sort" and "sortdir" + const updateSortFromURL = (sort?: URLState['sort']) => { + if (!sort) { + return; + } + + dispatch({ + type: 'onTableChange', + data: { + sort: { + field: sort.field, + direction: sort.direction, + }, + }, + }); + }; + + updateQueryFromURL(urlState.s); + updateSortFromURL(urlState.sort); + }, [urlState, searchQueryParser, getTagList, urlStateEnabled]); + useEffect(() => { isMounted.current = true; @@ -650,6 +893,7 @@ function TableListViewComp({ deleteItems={deleteItems} tableCaption={tableListTitle} onTableChange={onTableChange} + onTableSearchChange={onTableSearchChange} onSortChange={onSortChange} addOrRemoveIncludeTagFilter={addOrRemoveIncludeTagFilter} addOrRemoveExcludeTagFilter={addOrRemoveExcludeTagFilter} diff --git a/packages/content-management/table_list/src/use_tags.ts b/packages/content-management/table_list/src/use_tags.ts index c72f550bc54b..345a3484306f 100644 --- a/packages/content-management/table_list/src/use_tags.ts +++ b/packages/content-management/table_list/src/use_tags.ts @@ -43,8 +43,8 @@ export function useTags({ const updateTagClauseGetter = useCallback( (queryUpdater: QueryUpdater) => - (tag: Tag, q?: Query, doUpdate: boolean = true) => { - const updatedQuery = queryUpdater(q !== undefined ? q : query, tag); + (tag: Tag, q: Query = query, doUpdate: boolean = true) => { + const updatedQuery = queryUpdater(q, tag); if (doUpdate) { updateQuery(updatedQuery); } @@ -128,7 +128,7 @@ export function useTags({ } if (hasTagInExclude(tag, q)) { - // Already selected, remove the filter + // Already excluded, remove the filter removeTagFromExcludeClause(tag, q); return; } diff --git a/packages/content-management/table_list/src/use_url_state.ts b/packages/content-management/table_list/src/use_url_state.ts new file mode 100644 index 000000000000..2406ed597fbb --- /dev/null +++ b/packages/content-management/table_list/src/use_url_state.ts @@ -0,0 +1,53 @@ +/* + * 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 queryString from 'query-string'; +import { useCallback, useMemo, useState, useEffect } from 'react'; +import { useLocation, useHistory } from 'react-router-dom'; + +function useQuery = {}>() { + const { search } = useLocation(); + return useMemo(() => queryString.parse(search) as T, [search]); +} + +export function useUrlState< + T extends Record = {}, + Q extends Record = {} +>({ + queryParamsDeserializer, + queryParamsSerializer, +}: { + queryParamsDeserializer: (params: Q) => T; + queryParamsSerializer: (params: Record) => Partial; +}): [T, (updated: Record) => void] { + const history = useHistory(); + const params = useQuery(); + const [urlState, setUrlState] = useState({} as T); + + const updateQuerParams = useCallback( + (updated: Record) => { + const updatedQuery = queryParamsSerializer(updated); + + const queryParams = { + ...params, + ...updatedQuery, + }; + + history.replace({ + search: `?${queryString.stringify(queryParams, { encode: false })}`, + }); + }, + [history, params, queryParamsSerializer] + ); + + useEffect(() => { + const updatedState = queryParamsDeserializer(params); + setUrlState(updatedState); + }, [params, queryParamsDeserializer]); + + return [urlState, updateQuerParams]; +} diff --git a/src/plugins/dashboard/public/dashboard_app/listing/__snapshots__/dashboard_listing.test.tsx.snap b/src/plugins/dashboard/public/dashboard_app/listing/__snapshots__/dashboard_listing.test.tsx.snap index b16dd6e178a7..074697e980d1 100644 --- a/src/plugins/dashboard/public/dashboard_app/listing/__snapshots__/dashboard_listing.test.tsx.snap +++ b/src/plugins/dashboard/public/dashboard_app/listing/__snapshots__/dashboard_listing.test.tsx.snap @@ -78,7 +78,7 @@ exports[`after fetch When given a title that matches multiple dashboards, filter getDetailViewLink={[Function]} headingId="dashboardListingHeading" id="dashboard" - initialFilter="\\"search by title\\"" + initialFilter="search by title" tableListTitle="Dashboards" > { + return { + useLocation: () => ({ + search: '', + }), + useHistory: () => ({ + push: () => undefined, + }), + }; +}); + function makeDefaultProps(): DashboardListingProps { return { redirectTo: jest.fn(), @@ -49,6 +60,11 @@ function mountWith({ props: incomingProps }: { props?: DashboardListingProps }) { ui: { ...savedObjectsTagging, + parseSearchQuery: async () => ({ + searchTerm: '', + tagReferences: [], + tagReferencesToExclude: [], + }), components: { TagList: () => null, }, @@ -69,12 +85,15 @@ function mountWith({ props: incomingProps }: { props?: DashboardListingProps }) describe('after fetch', () => { test('renders all table rows', async () => { - const { component } = mountWith({}); - // Ensure all promises resolve - await new Promise((resolve) => process.nextTick(resolve)); + let component: ReactWrapper; + + await act(async () => { + ({ component } = mountWith({})); + }); + // Ensure the state changes are reflected - component.update(); - expect(component).toMatchSnapshot(); + component!.update(); + expect(component!).toMatchSnapshot(); }); test('renders call to action when no dashboards exist', async () => { @@ -85,12 +104,15 @@ describe('after fetch', () => { hits: [], }); - const { component } = mountWith({}); - // Ensure all promises resolve - await new Promise((resolve) => process.nextTick(resolve)); + let component: ReactWrapper; + + await act(async () => { + ({ component } = mountWith({})); + }); + // Ensure the state changes are reflected - component.update(); - expect(component).toMatchSnapshot(); + component!.update(); + expect(component!).toMatchSnapshot(); }); test('renders call to action with continue when no dashboards exist but one is in progress', async () => { @@ -105,23 +127,30 @@ describe('after fetch', () => { hits: [], }); - const { component } = mountWith({}); - // Ensure all promises resolve - await new Promise((resolve) => process.nextTick(resolve)); + let component: ReactWrapper; + + await act(async () => { + ({ component } = mountWith({})); + }); + // Ensure the state changes are reflected - component.update(); - expect(component).toMatchSnapshot(); + component!.update(); + expect(component!).toMatchSnapshot(); }); test('initialFilter', async () => { const props = makeDefaultProps(); props.initialFilter = 'testFilter'; - const { component } = mountWith({ props }); - // Ensure all promises resolve - await new Promise((resolve) => process.nextTick(resolve)); + + let component: ReactWrapper; + + await act(async () => { + ({ component } = mountWith({})); + }); + // Ensure the state changes are reflected - component.update(); - expect(component).toMatchSnapshot(); + component!.update(); + expect(component!).toMatchSnapshot(); }); test('When given a title that matches multiple dashboards, filter on the title', async () => { @@ -131,12 +160,16 @@ describe('after fetch', () => { ( pluginServices.getServices().dashboardSavedObject.findDashboards.findByTitle as jest.Mock ).mockResolvedValue(undefined); - const { component } = mountWith({ props }); - // Ensure all promises resolve - await new Promise((resolve) => process.nextTick(resolve)); + + let component: ReactWrapper; + + await act(async () => { + ({ component } = mountWith({ props })); + }); + // Ensure the state changes are reflected - component.update(); - expect(component).toMatchSnapshot(); + component!.update(); + expect(component!).toMatchSnapshot(); expect(props.redirectTo).not.toHaveBeenCalled(); }); @@ -147,11 +180,15 @@ describe('after fetch', () => { ( pluginServices.getServices().dashboardSavedObject.findDashboards.findByTitle as jest.Mock ).mockResolvedValue({ id: 'you_found_me' }); - const { component } = mountWith({ props }); - // Ensure all promises resolve - await new Promise((resolve) => process.nextTick(resolve)); + + let component: ReactWrapper; + + await act(async () => { + ({ component } = mountWith({ props })); + }); + // Ensure the state changes are reflected - component.update(); + component!.update(); expect(props.redirectTo).toHaveBeenCalledWith({ destination: 'dashboard', id: 'you_found_me', @@ -162,11 +199,14 @@ describe('after fetch', () => { test('showWriteControls', async () => { pluginServices.getServices().dashboardCapabilities.showWriteControls = false; - const { component } = mountWith({}); - // Ensure all promises resolve - await new Promise((resolve) => process.nextTick(resolve)); + let component: ReactWrapper; + + await act(async () => { + ({ component } = mountWith({})); + }); + // Ensure the state changes are reflected - component.update(); - expect(component).toMatchSnapshot(); + component!.update(); + expect(component!).toMatchSnapshot(); }); }); diff --git a/src/plugins/dashboard/public/dashboard_app/listing/dashboard_listing.tsx b/src/plugins/dashboard/public/dashboard_app/listing/dashboard_listing.tsx index f7de6f0e0845..ea2ecb151fb2 100644 --- a/src/plugins/dashboard/public/dashboard_app/listing/dashboard_listing.tsx +++ b/src/plugins/dashboard/public/dashboard_app/listing/dashboard_listing.tsx @@ -145,7 +145,7 @@ export const DashboardListing = ({ const listingLimit = uiSettings.get(SAVED_OBJECTS_LIMIT_SETTING); const initialPageSize = uiSettings.get(SAVED_OBJECTS_PER_PAGE_SETTING); - const defaultFilter = title ? `"${title}"` : ''; + const defaultFilter = title ? `${title}` : ''; const createItem = useCallback(() => { if (!dashboardSessionStorage.dashboardHasUnsavedEdits()) { diff --git a/src/plugins/dashboard/public/plugin.tsx b/src/plugins/dashboard/public/plugin.tsx index 00901b0849bb..84689c8feecc 100644 --- a/src/plugins/dashboard/public/plugin.tsx +++ b/src/plugins/dashboard/public/plugin.tsx @@ -187,14 +187,16 @@ export class DashboardPlugin // Do not save SEARCH_SESSION_ID into nav link, because of possible edge cases // that could lead to session restoration failure. // see: https://github.com/elastic/kibana/issues/87149 - if (newNavLink.includes(SEARCH_SESSION_ID)) { - newNavLink = replaceUrlHashQuery(newNavLink, (query) => { - delete query[SEARCH_SESSION_ID]; - return query; - }); - } - return newNavLink; + // We also don't want to store the table list view state. + // The question is: what _do_ we want to save here? :) + const tableListUrlState = ['s', 'title', 'sort', 'sortdir']; + return replaceUrlHashQuery(newNavLink, (query) => { + [SEARCH_SESSION_ID, ...tableListUrlState].forEach((param) => { + delete query[param]; + }); + return query; + }); }, }); diff --git a/src/plugins/saved_objects_tagging_oss/public/api.ts b/src/plugins/saved_objects_tagging_oss/public/api.ts index d7ea41c225f2..54afed5d6203 100644 --- a/src/plugins/saved_objects_tagging_oss/public/api.ts +++ b/src/plugins/saved_objects_tagging_oss/public/api.ts @@ -46,7 +46,7 @@ export interface ITagsCache { /** * Return an observable that will emit everytime the cache's state mutates. */ - getState$(): Observable; + getState$(params?: { waitForInitialization?: boolean }): Observable; } /** @@ -160,7 +160,7 @@ export interface SavedObjectsTaggingApiUi { * } * ``` */ - parseSearchQuery(query: string, options?: ParseSearchQueryOptions): ParsedSearchQuery; + parseSearchQuery(query: string, options?: ParseSearchQueryOptions): Promise; /** * Returns the object ids for the tag references from given references array diff --git a/src/plugins/visualizations/public/plugin.ts b/src/plugins/visualizations/public/plugin.ts index 1e92e69086e4..6628bd8a2208 100644 --- a/src/plugins/visualizations/public/plugin.ts +++ b/src/plugins/visualizations/public/plugin.ts @@ -23,6 +23,7 @@ import { createStartServicesGetter, Storage, withNotifyOnErrors, + replaceUrlHashQuery, } from '@kbn/kibana-utils-plugin/public'; import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public'; @@ -216,7 +217,13 @@ export class VisualizationsPlugin if (this.isLinkedToOriginatingApp?.()) { return core.http.basePath.prepend(VisualizeConstants.VISUALIZE_BASE_PATH); } - return urlToSave; + const tableListUrlState = ['s', 'title', 'sort', 'sortdir']; + return replaceUrlHashQuery(urlToSave, (query) => { + tableListUrlState.forEach((param) => { + delete query[param]; + }); + return query; + }); }, }); this.stopUrlTracking = () => { diff --git a/test/functional/apps/dashboard/group4/dashboard_listing.ts b/test/functional/apps/dashboard/group4/dashboard_listing.ts index 4a9827ce02f9..73d979ad140f 100644 --- a/test/functional/apps/dashboard/group4/dashboard_listing.ts +++ b/test/functional/apps/dashboard/group4/dashboard_listing.ts @@ -152,7 +152,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('preloads search filter bar when there is no match', async function () { const searchFilter = await listingTable.getSearchFilterValue(); - expect(searchFilter).to.equal('"nodashboardsnamedme"'); + expect(searchFilter).to.equal('nodashboardsnamedme'); }); it('stays on listing page if title matches two dashboards', async function () { @@ -172,7 +172,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('preloads search filter bar when there is more than one match', async function () { const searchFilter = await listingTable.getSearchFilterValue(); - expect(searchFilter).to.equal('"two words"'); + expect(searchFilter).to.equal('two words'); }); it('matches a title with many special characters', async function () { diff --git a/x-pack/plugins/saved_objects_tagging/public/services/tags/tags_cache.ts b/x-pack/plugins/saved_objects_tagging/public/services/tags/tags_cache.ts index 478218c3f359..51539c970c7f 100644 --- a/x-pack/plugins/saved_objects_tagging/public/services/tags/tags_cache.ts +++ b/x-pack/plugins/saved_objects_tagging/public/services/tags/tags_cache.ts @@ -7,7 +7,7 @@ import { Duration } from 'moment'; import { Observable, BehaviorSubject, Subject } from 'rxjs'; -import { takeUntil } from 'rxjs/operators'; +import { takeUntil, first, mergeMap } from 'rxjs/operators'; import { ITagsCache } from '@kbn/saved-objects-tagging-oss-plugin/public'; import { Tag, TagAttributes } from '../../../common/types'; @@ -41,6 +41,7 @@ export class TagsCache implements ITagsCache, ITagsChangeListener { private readonly internal$: BehaviorSubject; private readonly public$: Observable; private readonly stop$: Subject; + private isInitialized$: BehaviorSubject; constructor({ refreshHandler, refreshInterval }: TagsCacheOptions) { this.refreshHandler = refreshHandler; @@ -49,10 +50,12 @@ export class TagsCache implements ITagsCache, ITagsChangeListener { this.stop$ = new Subject(); this.internal$ = new BehaviorSubject([]); this.public$ = this.internal$.pipe(takeUntil(this.stop$)); + this.isInitialized$ = new BehaviorSubject(false); } public async initialize() { await this.refresh(); + this.isInitialized$.next(true); if (this.refreshInterval) { this.intervalId = window.setInterval(() => { @@ -74,8 +77,13 @@ export class TagsCache implements ITagsCache, ITagsChangeListener { return this.internal$.getValue(); } - public getState$() { - return this.public$; + public getState$({ waitForInitialization = false }: { waitForInitialization?: boolean } = {}) { + return waitForInitialization + ? this.isInitialized$.pipe( + first((isInitialized) => isInitialized), + mergeMap(() => this.public$) + ) + : this.public$; } public onDelete(id: string) { diff --git a/x-pack/plugins/saved_objects_tagging/public/ui_api/parse_search_query.test.ts b/x-pack/plugins/saved_objects_tagging/public/ui_api/parse_search_query.test.ts index 15e2349af47d..63a152822492 100644 --- a/x-pack/plugins/saved_objects_tagging/public/ui_api/parse_search_query.test.ts +++ b/x-pack/plugins/saved_objects_tagging/public/ui_api/parse_search_query.test.ts @@ -4,7 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - +import { of } from 'rxjs'; import { SavedObjectsTaggingApiUi } from '@kbn/saved-objects-tagging-oss-plugin/public'; import { tagsCacheMock } from '../services/tags/tags_cache.mock'; import { createTag } from '../../common/test_utils'; @@ -27,15 +27,15 @@ describe('parseSearchQuery', () => { beforeEach(() => { cache = tagsCacheMock.create(); - cache.getState.mockReturnValue(tags); + cache.getState$.mockReturnValue(of(tags)); parseSearchQuery = buildParseSearchQuery({ cache }); }); - it('returns the search term when there is no field clause', () => { + it('returns the search term when there is no field clause', async () => { const searchTerm = 'my search term'; - expect(parseSearchQuery(searchTerm)).toEqual({ + expect(await parseSearchQuery(searchTerm)).toEqual({ searchTerm, tagReferences: [], tagReferencesToExclude: [], @@ -43,10 +43,10 @@ describe('parseSearchQuery', () => { }); }); - it('returns the raw search term when the syntax is not valid', () => { + it('returns the raw search term when the syntax is not valid', async () => { const searchTerm = 'tag:id-1 [search term]'; - expect(parseSearchQuery(searchTerm)).toEqual({ + expect(await parseSearchQuery(searchTerm)).toEqual({ searchTerm, tagReferences: [], tagReferencesToExclude: [], @@ -54,10 +54,10 @@ describe('parseSearchQuery', () => { }); }); - it('returns the tag references matching the tag field clause when using `useName: false`', () => { + it('returns the tag references matching the tag field clause when using `useName: false`', async () => { const searchTerm = 'tag:(id-1 OR id-2) my search term'; - expect(parseSearchQuery(searchTerm, { useName: false })).toEqual({ + expect(await parseSearchQuery(searchTerm, { useName: false })).toEqual({ searchTerm: 'my search term', tagReferences: [tagRef('id-1'), tagRef('id-2')], tagReferencesToExclude: [], @@ -65,10 +65,10 @@ describe('parseSearchQuery', () => { }); }); - it('returns the tag references to exclude matching the tag field clause when using `useName: false`', () => { + it('returns the tag references to exclude matching the tag field clause when using `useName: false`', async () => { const searchTerm = '-tag:(id-1 OR id-2) my search term'; - expect(parseSearchQuery(searchTerm, { useName: false })).toEqual({ + expect(await parseSearchQuery(searchTerm, { useName: false })).toEqual({ searchTerm: 'my search term', tagReferences: [], tagReferencesToExclude: [tagRef('id-1'), tagRef('id-2')], @@ -76,10 +76,10 @@ describe('parseSearchQuery', () => { }); }); - it('returns the tag references matching the tag field clause when using `useName: true`', () => { + it('returns the tag references matching the tag field clause when using `useName: true`', async () => { const searchTerm = 'tag:(name-1 OR name-2) my search term'; - expect(parseSearchQuery(searchTerm, { useName: true })).toEqual({ + expect(await parseSearchQuery(searchTerm, { useName: true })).toEqual({ searchTerm: 'my search term', tagReferences: [tagRef('id-1'), tagRef('id-2')], tagReferencesToExclude: [], @@ -87,10 +87,10 @@ describe('parseSearchQuery', () => { }); }); - it('returns the tag references to exclude matching the tag field clause when using `useName: true`', () => { + it('returns the tag references to exclude matching the tag field clause when using `useName: true`', async () => { const searchTerm = '-tag:(name-1 OR name-2) my search term'; - expect(parseSearchQuery(searchTerm, { useName: true })).toEqual({ + expect(await parseSearchQuery(searchTerm, { useName: true })).toEqual({ searchTerm: 'my search term', tagReferences: [], tagReferencesToExclude: [tagRef('id-1'), tagRef('id-2')], @@ -98,10 +98,10 @@ describe('parseSearchQuery', () => { }); }); - it('uses the `tagField` option', () => { + it('uses the `tagField` option', async () => { const searchTerm = 'custom:(name-1 OR name-2) my search term'; - expect(parseSearchQuery(searchTerm, { tagField: 'custom' })).toEqual({ + expect(await parseSearchQuery(searchTerm, { tagField: 'custom' })).toEqual({ searchTerm: 'my search term', tagReferences: [tagRef('id-1'), tagRef('id-2')], tagReferencesToExclude: [], @@ -109,10 +109,10 @@ describe('parseSearchQuery', () => { }); }); - it('ignores names not in the cache', () => { + it('ignores names not in the cache', async () => { const searchTerm = 'tag:(name-1 OR missing-name) my search term'; - expect(parseSearchQuery(searchTerm, { useName: true })).toEqual({ + expect(await parseSearchQuery(searchTerm, { useName: true })).toEqual({ searchTerm: 'my search term', tagReferences: [tagRef('id-1')], tagReferencesToExclude: [], diff --git a/x-pack/plugins/saved_objects_tagging/public/ui_api/parse_search_query.ts b/x-pack/plugins/saved_objects_tagging/public/ui_api/parse_search_query.ts index 8f22fcea3f78..eef74321cb9c 100644 --- a/x-pack/plugins/saved_objects_tagging/public/ui_api/parse_search_query.ts +++ b/x-pack/plugins/saved_objects_tagging/public/ui_api/parse_search_query.ts @@ -5,6 +5,8 @@ * 2.0. */ +import { lastValueFrom } from 'rxjs'; +import { first } from 'rxjs/operators'; import { Query } from '@elastic/eui'; import { SavedObjectsFindOptionsReference } from '@kbn/core/public'; import { @@ -20,7 +22,10 @@ export interface BuildParseSearchQueryOptions { export const buildParseSearchQuery = ({ cache, }: BuildParseSearchQueryOptions): SavedObjectsTaggingApiUi['parseSearchQuery'] => { - return (query: string, { tagField = 'tag', useName = true }: ParseSearchQueryOptions = {}) => { + return async ( + query: string, + { tagField = 'tag', useName = true }: ParseSearchQueryOptions = {} + ) => { let parsed: Query; try { @@ -73,11 +78,15 @@ export const buildParseSearchQuery = ({ { selectedTags: [], excludedTags: [] } as { selectedTags: string[]; excludedTags: string[] } ); + const tagsInCache = await lastValueFrom( + cache.getState$({ waitForInitialization: true }).pipe(first()) + ); + const tagsToReferences = (tagNames: string[]) => { if (useName) { const references: SavedObjectsFindOptionsReference[] = []; tagNames.forEach((tagName) => { - const found = cache.getState().find((tag) => tag.name === tagName); + const found = tagsInCache.find((tag) => tag.name === tagName); if (found) { references.push({ type: 'tag',