[TableListView] Dashboard listing recently viewed sorting (#187564)

## Summary

Close https://github.com/elastic/kibana/issues/183686

Adds a new sorting option "Recently viewed". Recently viewed dashboards
will be shown at the top of the list. The remaining dashboards are
sorted by last updated at.
- This is a default option unless another option was explicitly selected
before (and saved in local storage)
- If there are no recently viewed dashboards, then this sorting option
is hidden, and "last updated at" is default like before
- This option is only added to the dashboard listing

Implementation: 

- Recently viewed dashboard are stored in local storage as a queue with
20 items max
- I reused the existing RecentlyAccessedService we've been using for
sidenav's recently viewed section. For this, I moved it to a separate
package. The service already handles a lot of edge cases like spaces,
key hashing, and deduping.
- The sorting part in EUITable is a bit hacky. It doesn't support custom
internal sorting (like we do with title and lastUpdatedAt), so I had to
sort the list myself and then tell EUITable not to do any sorting in
case "Recently viewed" option is selected. [slack
discussion](https://elastic.slack.com/archives/C7QC1JV6F/p1720008717120589)

<img width="1265" alt="Screenshot 2024-07-05 at 10 59 25"
src="9cc46fd2-4270-494f-9272-302007a7efc0">
This commit is contained in:
Anton Dosov 2024-07-09 16:38:56 +02:00 committed by GitHub
parent 34b96c51ce
commit e03fc63e48
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
38 changed files with 507 additions and 33 deletions

1
.github/CODEOWNERS vendored
View file

@ -659,6 +659,7 @@ packages/react/kibana_context/root @elastic/appex-sharedux
packages/react/kibana_context/styled @elastic/appex-sharedux
packages/react/kibana_context/theme @elastic/appex-sharedux
packages/react/kibana_mount @elastic/appex-sharedux
packages/kbn-recently-accessed @elastic/appex-sharedux
x-pack/plugins/remote_clusters @elastic/kibana-management
test/plugin_functional/plugins/rendering_plugin @elastic/kibana-core
packages/kbn-repo-file-maps @elastic/kibana-operations

View file

@ -675,6 +675,7 @@
"@kbn/react-kibana-context-styled": "link:packages/react/kibana_context/styled",
"@kbn/react-kibana-context-theme": "link:packages/react/kibana_context/theme",
"@kbn/react-kibana-mount": "link:packages/react/kibana_mount",
"@kbn/recently-accessed": "link:packages/kbn-recently-accessed",
"@kbn/remote-clusters-plugin": "link:x-pack/plugins/remote_clusters",
"@kbn/rendering-plugin": "link:test/plugin_functional/plugins/rendering_plugin",
"@kbn/repo-info": "link:packages/kbn-repo-info",

View file

@ -38,6 +38,7 @@ export type TableListViewProps<T extends UserContentCommonSchema = UserContentCo
| 'titleColumnName'
| 'withoutPageTemplateWrapper'
| 'createdByEnabled'
| 'recentlyAccessed'
> & {
title: string;
description?: string;
@ -75,6 +76,7 @@ export const TableListView = <T extends UserContentCommonSchema>({
additionalRightSideActions,
withoutPageTemplateWrapper,
createdByEnabled,
recentlyAccessed,
}: TableListViewProps<T>) => {
const PageTemplate = withoutPageTemplateWrapper
? (React.Fragment as unknown as typeof KibanaPageTemplate)
@ -124,6 +126,7 @@ export const TableListView = <T extends UserContentCommonSchema>({
onFetchSuccess={onFetchSuccess}
setPageDataTestSubject={setPageDataTestSubject}
createdByEnabled={createdByEnabled}
recentlyAccessed={recentlyAccessed}
/>
</KibanaPageTemplate.Section>
</PageTemplate>

View file

@ -13,7 +13,6 @@ import {
EuiButton,
EuiInMemoryTable,
CriteriaWithPagination,
PropertySort,
SearchFilterConfig,
Direction,
Query,
@ -59,6 +58,7 @@ interface Props<T extends UserContentCommonSchema> extends State<T>, TagManageme
tableCaption: string;
tableColumns: Array<EuiBasicTableColumn<T>>;
hasUpdatedAtMetadata: boolean;
hasRecentlyAccessedMetadata: boolean;
deleteItems: TableListViewTableProps<T>['deleteItems'];
tableItemsRowActions: TableItemsRowActions;
renderCreateButton: () => React.ReactElement | undefined;
@ -81,6 +81,7 @@ export function Table<T extends UserContentCommonSchema>({
tableSort,
tableFilter,
hasUpdatedAtMetadata,
hasRecentlyAccessedMetadata,
entityName,
entityNamePlural,
tagsToTableItemMap,
@ -174,12 +175,13 @@ export function Table<T extends UserContentCommonSchema>({
<TableSortSelect
tableSort={tableSort}
hasUpdatedAtMetadata={hasUpdatedAtMetadata}
hasRecentlyAccessedMetadata={hasRecentlyAccessedMetadata}
onChange={onSortChange}
/>
);
},
};
}, [hasUpdatedAtMetadata, onSortChange, tableSort]);
}, [hasUpdatedAtMetadata, onSortChange, tableSort, hasRecentlyAccessedMetadata]);
const tagFilterPanel = useMemo<SearchFilterConfig | null>(() => {
if (!isTaggingEnabled()) return null;
@ -278,6 +280,11 @@ export function Table<T extends UserContentCommonSchema>({
return { allUsers: Array.from(users), showNoUserOption: _showNoUserOption };
}, [createdByEnabled, items]);
const sorting =
tableSort.field === 'accessedAt' // "accessedAt" is a special case with a custom sorting
? true // by passing "true" we disable the EuiInMemoryTable sorting and handle it ourselves, but sorting is still enabled
: { sort: tableSort };
return (
<UserFilterContextProvider
enabled={createdByEnabled}
@ -298,7 +305,7 @@ export function Table<T extends UserContentCommonSchema>({
selection={selection}
search={search}
executeQueryOptions={{ enabled: false }}
sorting={tableSort ? { sort: tableSort as PropertySort } : undefined}
sorting={sorting}
onChange={onTableChange}
data-test-subj="itemsInMemTable"
rowHeader="attributes.title"

View file

@ -0,0 +1,64 @@
/*
* 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 { sortByRecentlyAccessed } from './table_sort_select';
import { UserContentCommonSchema } from '@kbn/content-management-table-list-view-common';
describe('sortByRecentlyAccessed', () => {
const items: UserContentCommonSchema[] = [
{
id: 'item-1',
type: 'dashboard',
updatedAt: '2020-01-01T00:00:00Z',
attributes: {
title: 'Item 1',
},
references: [],
},
{
id: 'item-2',
type: 'dashboard',
updatedAt: '2020-01-02T00:00:00Z',
attributes: {
title: 'Item 2',
},
createdBy: 'u_1',
references: [],
},
{
id: 'item-3',
type: 'dashboard',
updatedAt: '2020-01-03T00:00:00Z',
attributes: {
title: 'Item 3',
},
createdBy: 'u_2',
references: [],
},
{
id: 'item-4',
type: 'dashboard',
updatedAt: '2020-01-04T00:00:00Z',
attributes: {
title: 'Item 4',
},
references: [],
managed: true,
},
];
test('sort by last updated', () => {
const sortedItems = sortByRecentlyAccessed(items, []);
expect(sortedItems.map((item) => item.id)).toEqual(['item-4', 'item-3', 'item-2', 'item-1']);
});
test('pulls recently accessed to the top', () => {
const sortedItems = sortByRecentlyAccessed(items, [{ id: 'item-1' }, { id: 'item-2' }]);
expect(sortedItems.map((item) => item.id)).toEqual(['item-1', 'item-2', 'item-4', 'item-3']);
});
});

View file

@ -16,8 +16,10 @@ import {
Direction,
EuiText,
useEuiTheme,
EuiIconTip,
} from '@elastic/eui';
import { css } from '@emotion/react';
import { UserContentCommonSchema } from '@kbn/content-management-table-list-view-common';
import { State } from '../table_list_view_table';
@ -26,9 +28,15 @@ type SortItem = EuiSelectableOption & {
direction: Direction;
};
export type SortColumnField = 'updatedAt' | 'attributes.title';
export type SortColumnField = 'updatedAt' | 'attributes.title' | 'accessedAt';
const i18nText = {
accessedDescSort: i18n.translate(
'contentManagement.tableList.listing.tableSortSelect.recentlyAccessedLabel',
{
defaultMessage: 'Recently viewed',
}
),
nameAscSort: i18n.translate('contentManagement.tableList.listing.tableSortSelect.nameAscLabel', {
defaultMessage: 'Name A-Z',
}),
@ -57,11 +65,17 @@ const i18nText = {
interface Props {
hasUpdatedAtMetadata: boolean;
hasRecentlyAccessedMetadata: boolean;
tableSort: State['tableSort'];
onChange?: (column: SortColumnField, direction: Direction) => void;
}
export function TableSortSelect({ tableSort, hasUpdatedAtMetadata, onChange }: Props) {
export function TableSortSelect({
tableSort,
hasUpdatedAtMetadata,
hasRecentlyAccessedMetadata,
onChange,
}: Props) {
const { euiTheme } = useEuiTheme();
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
@ -81,6 +95,40 @@ export function TableSortSelect({ tableSort, hasUpdatedAtMetadata, onChange }: P
},
];
if (hasRecentlyAccessedMetadata) {
opts = [
{
label: i18nText.accessedDescSort,
column:
'accessedAt' /* nonexistent field, used to identify this custom type of sorting */,
direction: 'desc',
append: (
<EuiIconTip
aria-label={i18n.translate(
'contentManagement.tableList.listing.tableSortSelect.recentlyAccessedTipAriaLabel',
{
defaultMessage: 'Additional information',
}
)}
position="right"
color="inherit"
iconProps={{ style: { verticalAlign: 'text-bottom', marginLeft: 2 } }}
css={{ textWrap: 'balance' }}
type={'questionInCircle'}
content={i18n.translate(
'contentManagement.tableList.listing.tableSortSelect.recentlyAccessedTip',
{
defaultMessage:
'Recently viewed info is stored locally in your browser and is only visible to you.',
}
)}
/>
),
},
...opts,
];
}
if (hasUpdatedAtMetadata) {
opts = opts.concat([
{
@ -100,6 +148,7 @@ export function TableSortSelect({ tableSort, hasUpdatedAtMetadata, onChange }: P
return opts;
});
const selectedOptionLabel = options.find(({ checked }) => checked === 'on')?.label ?? '';
const panelHeaderCSS = css`
@ -165,8 +214,11 @@ export function TableSortSelect({ tableSort, hasUpdatedAtMetadata, onChange }: P
<>
<EuiText css={panelHeaderCSS}>{i18nText.headerSort}</EuiText>
<EuiSelectable<SortItem>
singleSelection
aria-label="some aria label"
singleSelection={'always'}
aria-label={i18n.translate(
'contentManagement.tableList.listing.tableSortSelect.sortingOptionsAriaLabel',
{ defaultMessage: 'Sorting options' }
)}
options={options}
onChange={onSelectChange}
data-test-subj="sortSelect"
@ -214,3 +266,25 @@ export function saveSorting(
/* empty */
}
}
/**
* Default custom sorting for the table when recently accessed info is available
* Sorts by recently accessed list first and the by lastUpdatedAt
*/
export function sortByRecentlyAccessed<T extends UserContentCommonSchema>(
items: T[],
recentlyAccessed: Array<{ id: string }>
) {
const recentlyAccessedMap = new Map(recentlyAccessed.map((item, index) => [item.id, index]));
return [...items].sort((a, b) => {
if (recentlyAccessedMap.has(a.id) && recentlyAccessedMap.has(b.id)) {
return recentlyAccessedMap.get(a.id)! - recentlyAccessedMap.get(b.id)!;
} else if (recentlyAccessedMap.has(a.id)) {
return -1;
} else if (recentlyAccessedMap.has(b.id)) {
return 1;
} else {
return a.updatedAt > b.updatedAt ? -1 : 1;
}
});
}

View file

@ -33,11 +33,18 @@ export function getReducer<T extends UserContentCommonSchema>() {
// 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 && !state.sortColumnChanged) {
tableSort = {
field: 'updatedAt' as const,
direction: 'desc' as const,
};
if (!state.sortColumnChanged) {
if (state.hasRecentlyAccessedMetadata) {
tableSort = {
field: 'accessedAt' as const,
direction: 'desc' as const,
};
} else if (hasUpdatedAtMetadata) {
tableSort = {
field: 'updatedAt' as const,
direction: 'desc' as const,
};
}
}
}

View file

@ -653,6 +653,91 @@ describe('TableListView', () => {
});
});
describe('column sorting with recently accessed', () => {
const setupColumnSorting = registerTestBed<string, TableListViewTableProps>(
WithServices<TableListViewTableProps>(TableListViewTable, {
TagList: getTagList({ references: [] }),
}),
{
defaultProps: {
...requiredProps,
recentlyAccessed: { get: () => [{ id: '123', link: '', label: '' }] },
},
memoryRouter: { wrapComponent: true },
}
);
const hits: UserContentCommonSchema[] = [
{
id: '123',
updatedAt: twoDaysAgo.toISOString(), // first asc, last desc
type: 'dashboard',
attributes: {
title: 'z-foo', // first desc, last asc
},
references: [{ id: 'id-tag-1', name: 'tag-1', type: 'tag' }],
},
{
id: '456',
updatedAt: yesterday.toISOString(), // first desc, last asc
type: 'dashboard',
attributes: {
title: 'a-foo', // first asc, last desc
},
references: [],
},
];
test('should initially sort by "Recently Accessed"', async () => {
let testBed: TestBed;
await act(async () => {
testBed = await setupColumnSorting({
findItems: jest.fn().mockResolvedValue({ total: hits.length, hits }),
});
});
const { component, table } = testBed!;
component.update();
const { tableCellsValues } = table.getMetaData('itemsInMemTable');
expect(tableCellsValues).toEqual([
['z-foo', twoDaysAgoToString],
['a-foo', yesterdayToString],
]);
});
test('filter select should have 5 options', async () => {
let testBed: TestBed;
await act(async () => {
testBed = await setupColumnSorting({
findItems: jest.fn().mockResolvedValue({ total: hits.length, hits }),
});
});
const { openSortSelect } = getActions(testBed!);
const { component, find } = testBed!;
component.update();
act(() => {
openSortSelect();
});
component.update();
const filterOptions = find('sortSelect').find('li');
expect(filterOptions.length).toBe(5);
expect(filterOptions.map((wrapper) => wrapper.text())).toEqual([
'Recently viewed. Checked option.Additional information ',
'Name A-Z ',
'Name Z-A ',
'Recently updated ',
'Least recently updated ',
]);
});
});
describe('content editor', () => {
const setupInspector = registerTestBed<string, TableListViewTableProps>(
WithServices<TableListViewTableProps>(TableListViewTable),

View file

@ -37,6 +37,7 @@ import type {
SavedObjectsReference,
} from '@kbn/content-management-content-editor';
import type { UserContentCommonSchema } from '@kbn/content-management-table-list-view-common';
import type { RecentlyAccessed } from '@kbn/recently-accessed';
import {
Table,
@ -52,6 +53,7 @@ import { type SortColumnField, getInitialSorting, saveSorting } from './componen
import { useTags } from './use_tags';
import { useInRouterContext, useUrlState } from './use_url_state';
import { RowActions, TableItemsRowActions } from './types';
import { sortByRecentlyAccessed } from './components/table_sort_select';
interface ContentEditorConfig
extends Pick<
@ -116,6 +118,7 @@ export interface TableListViewTableProps<
*/
withoutPageTemplateWrapper?: boolean;
contentEditor?: ContentEditorConfig;
recentlyAccessed?: Pick<RecentlyAccessed, 'get'>;
tableCaption: string;
/** Flag to force a new fetch of the table items. Whenever it changes, the `findItems()` will be called. */
@ -145,6 +148,7 @@ export interface State<T extends UserContentCommonSchema = UserContentCommonSche
totalItems: number;
hasUpdatedAtMetadata: boolean;
hasCreatedByMetadata: boolean;
hasRecentlyAccessedMetadata: boolean;
pagination: Pagination;
tableSort: {
field: SortColumnField;
@ -203,10 +207,19 @@ const urlStateDeserializer = (params: URLQueryParams): URLState => {
// 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';
if (
sanitizedParams.sort === 'title' ||
sanitizedParams.sort === 'updatedAt' ||
sanitizedParams.sort === 'accessedAt'
) {
const field =
sanitizedParams.sort === 'title'
? 'attributes.title'
: sanitizedParams.sort === 'accessedAt'
? 'accessedAt'
: 'updatedAt';
stateFromURL.sort = { field, direction: 'asc' };
stateFromURL.sort = { field, direction: field === 'attributes.title' ? 'asc' : 'desc' };
if (sanitizedParams.sortdir === 'desc' || sanitizedParams.sortdir === 'asc') {
stateFromURL.sort.direction = sanitizedParams.sortdir;
@ -302,6 +315,7 @@ function TableListViewTableComp<T extends UserContentCommonSchema>({
refreshListBouncer,
setPageDataTestSubject,
createdByEnabled = false,
recentlyAccessed,
}: TableListViewTableProps<T>) {
useEffect(() => {
setPageDataTestSubject(`${entityName}LandingPage`);
@ -373,6 +387,7 @@ function TableListViewTableComp<T extends UserContentCommonSchema>({
showDeleteModal: false,
hasUpdatedAtMetadata: false,
hasCreatedByMetadata: false,
hasRecentlyAccessedMetadata: recentlyAccessed ? recentlyAccessed.get().length > 0 : false,
selectedIds: [],
searchQuery: { text: '', query: new Query(Ast.create([]), undefined, '') },
pagination: {
@ -387,7 +402,7 @@ function TableListViewTableComp<T extends UserContentCommonSchema>({
createdBy: [],
},
};
}, [initialPageSize, entityName]);
}, [initialPageSize, entityName, recentlyAccessed]);
const [state, dispatch] = useReducer(reducer, initialState);
@ -404,6 +419,7 @@ function TableListViewTableComp<T extends UserContentCommonSchema>({
totalItems,
hasUpdatedAtMetadata,
hasCreatedByMetadata,
hasRecentlyAccessedMetadata,
pagination,
tableSort,
tableFilter,
@ -433,6 +449,12 @@ function TableListViewTableComp<T extends UserContentCommonSchema>({
}
if (idx === fetchIdx.current) {
// when recentlyAccessed is available, we sort the items by the recently accessed items
// then this sort will be used as the default sort for the table
if (recentlyAccessed && recentlyAccessed.get().length > 0) {
response.hits = sortByRecentlyAccessed(response.hits, recentlyAccessed.get());
}
dispatch({
type: 'onFetchItemsSuccess',
data: {
@ -448,7 +470,7 @@ function TableListViewTableComp<T extends UserContentCommonSchema>({
data: err,
});
}
}, [searchQueryParser, searchQuery.text, findItems, onFetchSuccess]);
}, [searchQueryParser, searchQuery.text, findItems, onFetchSuccess, recentlyAccessed]);
const updateQuery = useCallback(
(query: Query) => {
@ -1109,6 +1131,7 @@ function TableListViewTableComp<T extends UserContentCommonSchema>({
searchQuery={searchQuery}
tableColumns={tableColumns}
hasUpdatedAtMetadata={hasUpdatedAtMetadata}
hasRecentlyAccessedMetadata={hasRecentlyAccessedMetadata}
tableSort={tableSort}
tableFilter={tableFilter}
tableItemsRowActions={tableItemsRowActions}

View file

@ -34,7 +34,8 @@
"@kbn/user-profile-components",
"@kbn/core-user-profile-browser",
"@kbn/react-kibana-mount",
"@kbn/content-management-user-profiles"
"@kbn/content-management-user-profiles",
"@kbn/recently-accessed"
],
"exclude": [
"target/**/*"

View file

@ -39,13 +39,13 @@ import type {
SideNavComponent as ISideNavComponent,
ChromeHelpMenuLink,
} from '@kbn/core-chrome-browser';
import { RecentlyAccessedService } from '@kbn/recently-accessed';
import { Logger } from '@kbn/logging';
import { DocTitleService } from './doc_title';
import { NavControlsService } from './nav_controls';
import { NavLinksService } from './nav_links';
import { ProjectNavigationService } from './project_navigation';
import { RecentlyAccessedService } from './recently_accessed';
import { Header, LoadingIndicator, ProjectHeader } from './ui';
import { registerAnalyticsContextProvider } from './register_analytics_context_provider';
import type { InternalChromeStart } from './types';
@ -252,7 +252,7 @@ export class ChromeService {
chromeBreadcrumbs$: breadcrumbs$,
logger: this.logger,
});
const recentlyAccessed = await this.recentlyAccessed.start({ http });
const recentlyAccessed = this.recentlyAccessed.start({ http, key: 'recentlyAccessed' });
const docTitle = this.docTitle.start();
const { customBranding$ } = customBranding;
const helpMenuLinks$ = navControls.getHelpMenuLinks$();

View file

@ -16,7 +16,6 @@
"**/*.tsx",
],
"kbn_references": [
"@kbn/crypto-browser",
"@kbn/i18n",
"@kbn/i18n-react",
"@kbn/core-injected-metadata-browser-internal",
@ -55,6 +54,7 @@
"@kbn/core-i18n-browser-mocks",
"@kbn/core-theme-browser-mocks",
"@kbn/react-kibana-context-render",
"@kbn/recently-accessed",
],
"exclude": [
"target/**/*",

View file

@ -0,0 +1,4 @@
# @kbn/recently-accessed
The `RecentlyAccessedService` uses browser local storage to manage records of recently accessed objects.
This can be used to make recent items easier for users to find in listing UIs.

View file

@ -0,0 +1,13 @@
/*
* 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.
*/
export {
type RecentlyAccessed,
type RecentlyAccessedHistoryItem,
RecentlyAccessedService,
} from './src';

View file

@ -0,0 +1,13 @@
/*
* 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.
*/
module.exports = {
preset: '@kbn/test',
rootDir: '../..',
roots: ['<rootDir>/packages/kbn-recently-accessed'],
};

View file

@ -0,0 +1,5 @@
{
"type": "shared-common",
"id": "@kbn/recently-accessed",
"owner": "@elastic/appex-sharedux"
}

View file

@ -0,0 +1,6 @@
{
"name": "@kbn/recently-accessed",
"private": true,
"version": "1.0.0",
"license": "SSPL-1.0 OR Elastic License 2.0"
}

View file

@ -8,7 +8,7 @@
import { Sha256 } from '@kbn/crypto-browser';
export async function createLogKey(type: string, optionalIdentifier?: string) {
export function createLogKey(type: string, optionalIdentifier?: string) {
const baseKey = `kibana.history.${type}`;
if (!optionalIdentifier) {

View file

@ -7,3 +7,4 @@
*/
export { RecentlyAccessedService } from './recently_accessed_service';
export type { RecentlyAccessed, RecentlyAccessedHistoryItem } from './types';

View file

@ -54,7 +54,10 @@ describe('RecentlyAccessed#start()', () => {
const getStart = async () => {
const http = httpServiceMock.createStartContract();
const recentlyAccessed = await new RecentlyAccessedService().start({ http });
const recentlyAccessed = await new RecentlyAccessedService().start({
http,
key: 'recentlyAccessed',
});
return { http, recentlyAccessed };
};

View file

@ -6,23 +6,20 @@
* Side Public License, v 1.
*/
import type { InternalHttpStart } from '@kbn/core-http-browser-internal';
import type {
ChromeRecentlyAccessed,
ChromeRecentlyAccessedHistoryItem,
} from '@kbn/core-chrome-browser';
import type { HttpStart } from '@kbn/core-http-browser';
import type { RecentlyAccessed, RecentlyAccessedHistoryItem } from './types';
import { PersistedLog } from './persisted_log';
import { createLogKey } from './create_log_key';
interface StartDeps {
http: InternalHttpStart;
key: string;
http: Pick<HttpStart, 'basePath'>;
}
/** @internal */
export class RecentlyAccessedService {
async start({ http }: StartDeps): Promise<ChromeRecentlyAccessed> {
const logKey = await createLogKey('recentlyAccessed', http.basePath.get());
const history = new PersistedLog<ChromeRecentlyAccessedHistoryItem>(logKey, {
start({ http, key }: StartDeps): RecentlyAccessed {
const logKey = createLogKey(key, http.basePath.get());
const history = new PersistedLog<RecentlyAccessedHistoryItem>(logKey, {
maxLength: 20,
isEqual: (oldItem, newItem) => oldItem.id === newItem.id,
});

View file

@ -0,0 +1,56 @@
/*
* 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 { Observable } from 'rxjs';
/** @public */
export interface RecentlyAccessedHistoryItem {
link: string;
label: string;
id: string;
}
/**
* {@link RecentlyAccessed | APIs} for recently accessed history.
* @public
*/
export interface RecentlyAccessed {
/**
* Adds a new item to the recently accessed history.
*
* @example
* ```js
* chrome.recentlyAccessed.add('/app/map/1234', 'Map 1234', '1234');
* ```
*
* @param link a relative URL to the resource (not including the {@link HttpStart.basePath | `http.basePath`})
* @param label the label to display in the UI
* @param id a unique string used to de-duplicate the recently accessed list.
*/
add(link: string, label: string, id: string): void;
/**
* Gets an Array of the current recently accessed history.
*
* @example
* ```js
* recentlyAccessed.get().forEach(console.log);
* ```
*/
get(): RecentlyAccessedHistoryItem[];
/**
* Gets an Observable of the array of recently accessed history.
*
* @example
* ```js
* recentlyAccessed.get$().subscribe(console.log);
* ```
*/
get$(): Observable<RecentlyAccessedHistoryItem[]>;
}

View file

@ -0,0 +1,21 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "target/types",
"types": [
"jest",
"node"
]
},
"include": [
"**/*.ts",
],
"exclude": [
"target/**/*"
],
"kbn_references": [
"@kbn/crypto-browser",
"@kbn/core-http-browser",
"@kbn/core-http-browser-mocks",
]
}

View file

@ -157,6 +157,7 @@ describe('useDashboardListingTable', () => {
showActivityView: true,
},
createdByEnabled: true,
recentlyAccessed: expect.objectContaining({ get: expect.any(Function) }),
};
expect(tableListViewTableProps).toEqual(expectedProps);

View file

@ -100,6 +100,7 @@ export const useDashboardListingTable = ({
checkForDuplicateDashboardTitle,
},
notifications: { toasts },
dashboardRecentlyAccessed,
} = pluginServices.getServices();
const { getEntityName, getTableListTitle, getEntityNamePlural } = dashboardListingTableStrings;
@ -302,6 +303,7 @@ export const useDashboardListingTable = ({
title,
urlStateEnabled,
createdByEnabled: true,
recentlyAccessed: dashboardRecentlyAccessed,
}),
[
contentEditorValidators,
@ -324,6 +326,7 @@ export const useDashboardListingTable = ({
title,
updateItemMeta,
urlStateEnabled,
dashboardRecentlyAccessed,
]
);

View file

@ -83,6 +83,7 @@ export function InternalDashboardTopNav({
embeddable: { getStateTransfer },
initializerContext: { allowByValueEmbeddables },
dashboardCapabilities: { saveQuery: allowSaveQuery, showWriteControls },
dashboardRecentlyAccessed,
} = pluginServices.getServices();
const isLabsEnabled = uiSettings.get(UI_SETTINGS.ENABLE_LABS_UI);
const { setHeaderActionMenu, onAppLeave } = useDashboardMountContext();
@ -143,6 +144,11 @@ export function InternalDashboardTopNav({
title,
lastSavedId
);
dashboardRecentlyAccessed.add(
getFullEditPath(lastSavedId, viewMode === ViewMode.EDIT),
title,
lastSavedId
);
}
return () => subscription.unsubscribe();
}, [
@ -152,6 +158,7 @@ export function InternalDashboardTopNav({
lastSavedId,
viewMode,
title,
dashboardRecentlyAccessed,
]);
/**

View file

@ -0,0 +1,22 @@
/*
* 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 { PluginServiceFactory } from '@kbn/presentation-util-plugin/public';
import { DashboardRecentlyAccessedService } from './types';
type DashboardRecentlyAccessedServiceFactory =
PluginServiceFactory<DashboardRecentlyAccessedService>;
export const dashboardRecentlyAccessedServiceFactory: DashboardRecentlyAccessedServiceFactory =
() => {
return {
add: jest.fn(),
get: jest.fn(),
get$: jest.fn(),
};
};

View file

@ -0,0 +1,32 @@
/*
* 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 { RecentlyAccessedService } from '@kbn/recently-accessed';
import type { KibanaPluginServiceFactory } from '@kbn/presentation-util-plugin/public';
import { DashboardHTTPService } from '../http/types';
import { DashboardStartDependencies } from '../../plugin';
import { DashboardRecentlyAccessedService } from './types';
interface DashboardRecentlyAccessedRequiredServices {
http: DashboardHTTPService;
}
export type DashboardBackupServiceFactory = KibanaPluginServiceFactory<
DashboardRecentlyAccessedService,
DashboardStartDependencies,
DashboardRecentlyAccessedRequiredServices
>;
export const dashboardRecentlyAccessedFactory: DashboardBackupServiceFactory = (
core,
requiredServices
) => {
const { http } = requiredServices;
return new RecentlyAccessedService().start({ http, key: 'dashboardRecentlyAccessed' });
};

View file

@ -0,0 +1,11 @@
/*
* 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 { RecentlyAccessed } from '@kbn/recently-accessed';
export type DashboardRecentlyAccessedService = RecentlyAccessed;

View file

@ -47,6 +47,7 @@ import { userProfileServiceFactory } from './user_profile/user_profile_service.s
import { observabilityAIAssistantServiceStubFactory } from './observability_ai_assistant/observability_ai_assistant_service.stub';
import { noDataPageServiceFactory } from './no_data_page/no_data_page_service.stub';
import { uiActionsServiceFactory } from './ui_actions/ui_actions_service.stub';
import { dashboardRecentlyAccessedServiceFactory } from './dashboard_recently_accessed/dashboard_recently_accessed.stub';
export const providers: PluginServiceProviders<DashboardServices> = {
dashboardContentManagement: new PluginServiceProvider(dashboardContentManagementServiceFactory),
@ -82,6 +83,7 @@ export const providers: PluginServiceProviders<DashboardServices> = {
uiActions: new PluginServiceProvider(uiActionsServiceFactory),
userProfile: new PluginServiceProvider(userProfileServiceFactory),
observabilityAIAssistant: new PluginServiceProvider(observabilityAIAssistantServiceStubFactory),
dashboardRecentlyAccessed: new PluginServiceProvider(dashboardRecentlyAccessedServiceFactory),
};
export const registry = new PluginServiceRegistry<DashboardServices>(providers);

View file

@ -48,6 +48,7 @@ import { noDataPageServiceFactory } from './no_data_page/no_data_page_service';
import { uiActionsServiceFactory } from './ui_actions/ui_actions_service';
import { observabilityAIAssistantServiceFactory } from './observability_ai_assistant/observability_ai_assistant_service';
import { userProfileServiceFactory } from './user_profile/user_profile_service';
import { dashboardRecentlyAccessedFactory } from './dashboard_recently_accessed/dashboard_recently_accessed';
const providers: PluginServiceProviders<DashboardServices, DashboardPluginServiceParams> = {
dashboardContentManagement: new PluginServiceProvider(dashboardContentManagementServiceFactory, [
@ -96,6 +97,7 @@ const providers: PluginServiceProviders<DashboardServices, DashboardPluginServic
uiActions: new PluginServiceProvider(uiActionsServiceFactory),
observabilityAIAssistant: new PluginServiceProvider(observabilityAIAssistantServiceFactory),
userProfile: new PluginServiceProvider(userProfileServiceFactory),
dashboardRecentlyAccessed: new PluginServiceProvider(dashboardRecentlyAccessedFactory, ['http']),
};
export const pluginServices = new PluginServices<DashboardServices>();

View file

@ -43,6 +43,7 @@ import { NoDataPageService } from './no_data_page/types';
import { DashboardUiActionsService } from './ui_actions/types';
import { ObservabilityAIAssistantService } from './observability_ai_assistant/types';
import { DashboardUserProfileService } from './user_profile/types';
import { DashboardRecentlyAccessedService } from './dashboard_recently_accessed/types';
export type DashboardPluginServiceParams = KibanaPluginServiceParams<DashboardStartDependencies> & {
initContext: PluginInitializerContext; // need a custom type so that initContext is a required parameter for initializerContext
@ -82,4 +83,5 @@ export interface DashboardServices {
uiActions: DashboardUiActionsService;
observabilityAIAssistant: ObservabilityAIAssistantService; // TODO: make this optional in follow up
userProfile: DashboardUserProfileService;
dashboardRecentlyAccessed: DashboardRecentlyAccessedService;
}

View file

@ -83,6 +83,7 @@
"@kbn/esql-utils",
"@kbn/lens-embeddable-utils",
"@kbn/lens-plugin",
"@kbn/recently-accessed",
],
"exclude": ["target/**/*"]
}

View file

@ -1312,6 +1312,8 @@
"@kbn/react-kibana-context-theme/*": ["packages/react/kibana_context/theme/*"],
"@kbn/react-kibana-mount": ["packages/react/kibana_mount"],
"@kbn/react-kibana-mount/*": ["packages/react/kibana_mount/*"],
"@kbn/recently-accessed": ["packages/kbn-recently-accessed"],
"@kbn/recently-accessed/*": ["packages/kbn-recently-accessed/*"],
"@kbn/remote-clusters-plugin": ["x-pack/plugins/remote_clusters"],
"@kbn/remote-clusters-plugin/*": ["x-pack/plugins/remote_clusters/*"],
"@kbn/rendering-plugin": ["test/plugin_functional/plugins/rendering_plugin"],

View file

@ -5837,6 +5837,10 @@
version "0.0.0"
uid ""
"@kbn/recently-accessed@link:packages/kbn-recently-accessed":
version "0.0.0"
uid ""
"@kbn/remote-clusters-plugin@link:x-pack/plugins/remote_clusters":
version "0.0.0"
uid ""