mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
[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:
parent
34b96c51ce
commit
e03fc63e48
38 changed files with 507 additions and 33 deletions
1
.github/CODEOWNERS
vendored
1
.github/CODEOWNERS
vendored
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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']);
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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/**/*"
|
||||
|
|
|
@ -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$();
|
||||
|
|
|
@ -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/**/*",
|
||||
|
|
4
packages/kbn-recently-accessed/README.md
Normal file
4
packages/kbn-recently-accessed/README.md
Normal 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.
|
13
packages/kbn-recently-accessed/index.ts
Normal file
13
packages/kbn-recently-accessed/index.ts
Normal 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';
|
13
packages/kbn-recently-accessed/jest.config.js
Normal file
13
packages/kbn-recently-accessed/jest.config.js
Normal 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'],
|
||||
};
|
5
packages/kbn-recently-accessed/kibana.jsonc
Normal file
5
packages/kbn-recently-accessed/kibana.jsonc
Normal file
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"type": "shared-common",
|
||||
"id": "@kbn/recently-accessed",
|
||||
"owner": "@elastic/appex-sharedux"
|
||||
}
|
6
packages/kbn-recently-accessed/package.json
Normal file
6
packages/kbn-recently-accessed/package.json
Normal file
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"name": "@kbn/recently-accessed",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"license": "SSPL-1.0 OR Elastic License 2.0"
|
||||
}
|
|
@ -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) {
|
|
@ -7,3 +7,4 @@
|
|||
*/
|
||||
|
||||
export { RecentlyAccessedService } from './recently_accessed_service';
|
||||
export type { RecentlyAccessed, RecentlyAccessedHistoryItem } from './types';
|
|
@ -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 };
|
||||
};
|
||||
|
|
@ -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,
|
||||
});
|
56
packages/kbn-recently-accessed/src/types.ts
Normal file
56
packages/kbn-recently-accessed/src/types.ts
Normal 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[]>;
|
||||
}
|
21
packages/kbn-recently-accessed/tsconfig.json
Normal file
21
packages/kbn-recently-accessed/tsconfig.json
Normal 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",
|
||||
]
|
||||
}
|
|
@ -157,6 +157,7 @@ describe('useDashboardListingTable', () => {
|
|||
showActivityView: true,
|
||||
},
|
||||
createdByEnabled: true,
|
||||
recentlyAccessed: expect.objectContaining({ get: expect.any(Function) }),
|
||||
};
|
||||
|
||||
expect(tableListViewTableProps).toEqual(expectedProps);
|
||||
|
|
|
@ -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,
|
||||
]
|
||||
);
|
||||
|
||||
|
|
|
@ -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,
|
||||
]);
|
||||
|
||||
/**
|
||||
|
|
|
@ -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(),
|
||||
};
|
||||
};
|
|
@ -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' });
|
||||
};
|
|
@ -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;
|
|
@ -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);
|
||||
|
|
|
@ -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>();
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -83,6 +83,7 @@
|
|||
"@kbn/esql-utils",
|
||||
"@kbn/lens-embeddable-utils",
|
||||
"@kbn/lens-plugin",
|
||||
"@kbn/recently-accessed",
|
||||
],
|
||||
"exclude": ["target/**/*"]
|
||||
}
|
||||
|
|
|
@ -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"],
|
||||
|
|
|
@ -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 ""
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue