mirror of
https://github.com/elastic/kibana.git
synced 2025-04-18 23:21:39 -04:00
[Discover][Tabs] New tabs package and initial implementation of components (#210674)
- Closes https://github.com/elastic/kibana/issues/210500 - Closes https://github.com/elastic/kibana/issues/210502 ## Summary This PR introduces a new package `@kbn/unified-tabs` which includes an initial implementation for tabs UI/UX which we are planning to add to Discover. - [x] New package - [x] Storybook support - [x] Initial styles and interactions - [x] New example plugin for testing together with UnifiedSearch bar - [x] Minimal tests ### In the new Storybook Start Storybook with `NODE_OPTIONS="--openssl-legacy-provider" node scripts/storybook unified_tabs` and navigate to `http://localhost:9001`. <img width="1024" alt="Screenshot 2025-02-12 at 13 35 46" src="https://github.com/user-attachments/assets/0723b0c4-c3f7-44f8-af8d-f68d7a7b6ea8" /> ### In the new Unified Tabs example plugin Start Kibana with `yarn start --run-examples`. Then navigate to the Unified Tabs example plugin `http://localhost:5601/app/unifiedTabsExamples`. <img width="1221" alt="Screenshot 2025-02-12 at 16 11 55" src="https://github.com/user-attachments/assets/2edff817-0aae-424c-978c-c4c67450c9eb" /> <img width="1219" alt="Screenshot 2025-02-12 at 16 13 57" src="https://github.com/user-attachments/assets/2e6e6b0b-88e9-4689-a175-9612e8507535" />  ### Checklist - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/src/platform/packages/shared/kbn-i18n/README.md) - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [x] The PR description includes the appropriate Release Notes section, and the correct `release_note:*` label is applied per the [guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Davis McPhee <davismcphee@hotmail.com>
This commit is contained in:
parent
4783925e56
commit
705df212b8
39 changed files with 1423 additions and 56 deletions
2
.github/CODEOWNERS
vendored
2
.github/CODEOWNERS
vendored
|
@ -39,6 +39,7 @@ examples/ui_action_examples @elastic/appex-sharedux
|
|||
examples/ui_actions_explorer @elastic/appex-sharedux
|
||||
examples/unified_doc_viewer @elastic/kibana-core
|
||||
examples/unified_field_list_examples @elastic/kibana-data-discovery
|
||||
examples/unified_tabs_examples @elastic/kibana-data-discovery
|
||||
examples/user_profile_examples @elastic/kibana-security
|
||||
examples/v8_profiler_examples @elastic/response-ops
|
||||
packages/kbn-ambient-common-types @elastic/kibana-operations
|
||||
|
@ -539,6 +540,7 @@ src/platform/packages/shared/kbn-ui-theme @elastic/kibana-operations
|
|||
src/platform/packages/shared/kbn-unified-data-table @elastic/kibana-data-discovery @elastic/security-threat-hunting-investigations
|
||||
src/platform/packages/shared/kbn-unified-doc-viewer @elastic/kibana-data-discovery
|
||||
src/platform/packages/shared/kbn-unified-field-list @elastic/kibana-data-discovery
|
||||
src/platform/packages/shared/kbn-unified-tabs @elastic/kibana-data-discovery
|
||||
src/platform/packages/shared/kbn-unsaved-changes-prompt @elastic/kibana-management
|
||||
src/platform/packages/shared/kbn-use-tracked-promise @elastic/obs-ux-logs-team
|
||||
src/platform/packages/shared/kbn-user-profile-components @elastic/kibana-security
|
||||
|
|
|
@ -163,6 +163,7 @@
|
|||
"unifiedFieldList": "src/platform/packages/shared/kbn-unified-field-list",
|
||||
"unifiedHistogram": "src/platform/plugins/shared/unified_histogram",
|
||||
"unifiedDataTable": "src/platform/packages/shared/kbn-unified-data-table",
|
||||
"unifiedTabs": "src/platform/packages/shared/kbn-unified-tabs",
|
||||
"dataGridInTableSearch": "src/platform/packages/shared/kbn-data-grid-in-table-search",
|
||||
"unsavedChangesBadge": "src/platform/packages/private/kbn-unsaved-changes-badge",
|
||||
"unsavedChangesPrompt": "src/platform/packages/shared/kbn-unsaved-changes-prompt",
|
||||
|
|
|
@ -61,64 +61,66 @@ function DeveloperExamples({ startServices, examples, navigateToApp, getUrlForAp
|
|||
|
||||
return (
|
||||
<KibanaRenderContextProvider {...startServices}>
|
||||
<EuiPageTemplate.Header>
|
||||
<EuiFlexGroup justifyContent={'spaceBetween'}>
|
||||
<EuiFlexItem>
|
||||
<EuiPageHeader pageTitle={'Developer examples'} />
|
||||
<EuiText>
|
||||
The following examples showcase services and APIs that are available to developers.
|
||||
</EuiText>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<EuiFieldSearch
|
||||
fullWidth
|
||||
placeholder="Search"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
isClearable={true}
|
||||
aria-label="Search developer examples"
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiPageTemplate.Header>
|
||||
<EuiPageTemplate.Section>
|
||||
<EuiFlexGroup wrap>
|
||||
{filteredExamples.map((def) => (
|
||||
<EuiFlexItem style={{ minWidth: 300, maxWidth: 500 }} key={def.appId}>
|
||||
<EuiCard
|
||||
description={
|
||||
<EuiHighlight search={search} highlightAll={true}>
|
||||
{def.description}
|
||||
</EuiHighlight>
|
||||
}
|
||||
title={
|
||||
<React.Fragment>
|
||||
<EuiLink
|
||||
onClick={() => {
|
||||
navigateToApp(def.appId);
|
||||
}}
|
||||
>
|
||||
<EuiHighlight search={search} highlightAll={true}>
|
||||
{def.title}
|
||||
</EuiHighlight>
|
||||
</EuiLink>
|
||||
<EuiButtonIcon
|
||||
iconType="popout"
|
||||
onClick={() =>
|
||||
window.open(getUrlForApp(def.appId), '_blank', 'noopener, noreferrer')
|
||||
}
|
||||
>
|
||||
Open in new tab
|
||||
</EuiButtonIcon>
|
||||
</React.Fragment>
|
||||
}
|
||||
image={def.image}
|
||||
footer={def.links ? <EuiListGroup size={'s'} listItems={def.links} /> : undefined}
|
||||
<EuiPageTemplate offset={0}>
|
||||
<EuiPageTemplate.Header>
|
||||
<EuiFlexGroup justifyContent={'spaceBetween'}>
|
||||
<EuiFlexItem>
|
||||
<EuiPageHeader pageTitle={'Developer examples'} />
|
||||
<EuiText>
|
||||
The following examples showcase services and APIs that are available to developers.
|
||||
</EuiText>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<EuiFieldSearch
|
||||
fullWidth
|
||||
placeholder="Search"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
isClearable={true}
|
||||
aria-label="Search developer examples"
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
))}
|
||||
</EuiFlexGroup>
|
||||
</EuiPageTemplate.Section>
|
||||
</EuiFlexGroup>
|
||||
</EuiPageTemplate.Header>
|
||||
<EuiPageTemplate.Section>
|
||||
<EuiFlexGroup wrap>
|
||||
{filteredExamples.map((def) => (
|
||||
<EuiFlexItem style={{ minWidth: 300, maxWidth: 500 }} key={def.appId}>
|
||||
<EuiCard
|
||||
description={
|
||||
<EuiHighlight search={search} highlightAll={true}>
|
||||
{def.description}
|
||||
</EuiHighlight>
|
||||
}
|
||||
title={
|
||||
<React.Fragment>
|
||||
<EuiLink
|
||||
onClick={() => {
|
||||
navigateToApp(def.appId);
|
||||
}}
|
||||
>
|
||||
<EuiHighlight search={search} highlightAll={true}>
|
||||
{def.title}
|
||||
</EuiHighlight>
|
||||
</EuiLink>
|
||||
<EuiButtonIcon
|
||||
iconType="popout"
|
||||
onClick={() =>
|
||||
window.open(getUrlForApp(def.appId), '_blank', 'noopener, noreferrer')
|
||||
}
|
||||
>
|
||||
Open in new tab
|
||||
</EuiButtonIcon>
|
||||
</React.Fragment>
|
||||
}
|
||||
image={def.image}
|
||||
footer={def.links ? <EuiListGroup size={'s'} listItems={def.links} /> : undefined}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
))}
|
||||
</EuiFlexGroup>
|
||||
</EuiPageTemplate.Section>
|
||||
</EuiPageTemplate>
|
||||
</KibanaRenderContextProvider>
|
||||
);
|
||||
}
|
||||
|
|
9
examples/unified_tabs_examples/README.md
Normal file
9
examples/unified_tabs_examples/README.md
Normal file
|
@ -0,0 +1,9 @@
|
|||
# unified_tabs_examples
|
||||
|
||||
Examples of unified tabs components.
|
||||
|
||||
To run this example, ensure you have data to search against (for example, the sample datasets) and start kibana with the `--run-examples` flag.
|
||||
|
||||
```bash
|
||||
yarn start --run-examples
|
||||
```
|
11
examples/unified_tabs_examples/common/index.ts
Normal file
11
examples/unified_tabs_examples/common/index.ts
Normal 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
export const PLUGIN_ID = 'unifiedTabsExamples';
|
||||
export const PLUGIN_NAME = 'Unified Tabs Examples';
|
24
examples/unified_tabs_examples/kibana.jsonc
Normal file
24
examples/unified_tabs_examples/kibana.jsonc
Normal file
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"type": "plugin",
|
||||
"id": "@kbn/unified-tabs-examples-plugin",
|
||||
"owner": "@elastic/kibana-data-discovery",
|
||||
"description": "Examples of using unified tabs.",
|
||||
"plugin": {
|
||||
"id": "unifiedTabsExamples",
|
||||
"server": false,
|
||||
"browser": true,
|
||||
"requiredPlugins": [
|
||||
"navigation",
|
||||
"developerExamples",
|
||||
"inspector",
|
||||
"kibanaUtils",
|
||||
"unifiedSearch",
|
||||
"data",
|
||||
"dataViews",
|
||||
"dataViewFieldEditor",
|
||||
"charts",
|
||||
"fieldFormats",
|
||||
"uiActions"
|
||||
]
|
||||
}
|
||||
}
|
41
examples/unified_tabs_examples/public/application.tsx
Normal file
41
examples/unified_tabs_examples/public/application.tsx
Normal file
|
@ -0,0 +1,41 @@
|
|||
/*
|
||||
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { I18nProvider } from '@kbn/i18n-react';
|
||||
import { KibanaThemeProvider } from '@kbn/react-kibana-context-theme';
|
||||
import type { AppMountParameters, CoreStart } from '@kbn/core/public';
|
||||
import { AppPluginStartDependencies } from './types';
|
||||
import { UnifiedTabsExampleApp } from './example_app';
|
||||
|
||||
export const renderApp = (
|
||||
core: CoreStart,
|
||||
deps: AppPluginStartDependencies,
|
||||
{ element, setHeaderActionMenu }: AppMountParameters
|
||||
) => {
|
||||
ReactDOM.render(
|
||||
<I18nProvider>
|
||||
<KibanaThemeProvider {...core}>
|
||||
<UnifiedTabsExampleApp
|
||||
services={{
|
||||
core,
|
||||
...deps,
|
||||
}}
|
||||
setHeaderActionMenu={setHeaderActionMenu}
|
||||
/>
|
||||
</KibanaThemeProvider>
|
||||
</I18nProvider>,
|
||||
element
|
||||
);
|
||||
|
||||
return () => {
|
||||
ReactDOM.unmountComponentAtNode(element);
|
||||
};
|
||||
};
|
234
examples/unified_tabs_examples/public/example_app.tsx
Normal file
234
examples/unified_tabs_examples/public/example_app.tsx
Normal file
|
@ -0,0 +1,234 @@
|
|||
/*
|
||||
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiPage,
|
||||
EuiPageBody,
|
||||
EuiEmptyPrompt,
|
||||
EuiLoadingLogo,
|
||||
useEuiTheme,
|
||||
EuiPanel,
|
||||
} from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { css } from '@emotion/react';
|
||||
import type { AppMountParameters } from '@kbn/core-application-browser';
|
||||
import type { DataView } from '@kbn/data-views-plugin/public';
|
||||
import type { DataViewField } from '@kbn/data-views-plugin/public';
|
||||
import type { DataViewPickerProps } from '@kbn/unified-search-plugin/public';
|
||||
import { UnifiedTabs } from '@kbn/unified-tabs';
|
||||
import { PLUGIN_ID, PLUGIN_NAME } from '../common';
|
||||
import { FieldListSidebar, FieldListSidebarProps } from './field_list_sidebar';
|
||||
|
||||
let TMP_COUNTER = 0;
|
||||
|
||||
interface UnifiedTabsExampleAppProps {
|
||||
services: FieldListSidebarProps['services'];
|
||||
setHeaderActionMenu: AppMountParameters['setHeaderActionMenu'];
|
||||
}
|
||||
|
||||
export const UnifiedTabsExampleApp: React.FC<UnifiedTabsExampleAppProps> = ({
|
||||
services,
|
||||
setHeaderActionMenu,
|
||||
}) => {
|
||||
const { euiTheme } = useEuiTheme();
|
||||
const { navigation, data, unifiedSearch } = services;
|
||||
const { IndexPatternSelect } = unifiedSearch.ui;
|
||||
const [dataView, setDataView] = useState<DataView | null>();
|
||||
const [selectedFieldNames, setSelectedFieldNames] = useState<string[]>([]);
|
||||
|
||||
const onAddFieldToWorkspace = useCallback(
|
||||
(field: DataViewField) => {
|
||||
setSelectedFieldNames((names) => [...names, field.name]);
|
||||
},
|
||||
[setSelectedFieldNames]
|
||||
);
|
||||
|
||||
const onRemoveFieldFromWorkspace = useCallback(
|
||||
(field: DataViewField) => {
|
||||
setSelectedFieldNames((names) => names.filter((name) => name !== field.name));
|
||||
},
|
||||
[setSelectedFieldNames]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const setDefaultDataView = async () => {
|
||||
try {
|
||||
const defaultDataView = await data.dataViews.getDefault();
|
||||
setDataView(defaultDataView);
|
||||
} catch (e) {
|
||||
setDataView(null);
|
||||
}
|
||||
};
|
||||
|
||||
setDefaultDataView();
|
||||
}, [data]);
|
||||
|
||||
if (typeof dataView === 'undefined') {
|
||||
return (
|
||||
<EuiEmptyPrompt
|
||||
icon={<EuiLoadingLogo logo="logoKibana" size="xl" />}
|
||||
title={<h2>{PLUGIN_NAME}</h2>}
|
||||
body={<p>Loading...</p>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const SearchBar = navigation.ui.AggregateQueryTopNavMenu;
|
||||
|
||||
return (
|
||||
<EuiPage
|
||||
grow={true}
|
||||
css={css`
|
||||
background-color: ${euiTheme.colors.backgroundBasePlain};
|
||||
`}
|
||||
>
|
||||
<EuiPageBody paddingSize="none">
|
||||
{dataView ? (
|
||||
<div className="eui-fullHeight">
|
||||
<UnifiedTabs
|
||||
initialItems={[
|
||||
{
|
||||
id: 'tab_initial',
|
||||
label: 'Initial tab',
|
||||
},
|
||||
]}
|
||||
onChanged={() => {}}
|
||||
createItem={() => {
|
||||
TMP_COUNTER += 1;
|
||||
return {
|
||||
id: `tab_${TMP_COUNTER}`,
|
||||
label: `Tab ${TMP_COUNTER}`,
|
||||
};
|
||||
}}
|
||||
renderContent={({ label }) => {
|
||||
return (
|
||||
<EuiFlexGroup direction="column" gutterSize="none">
|
||||
<EuiFlexItem grow={false}>
|
||||
<SearchBar
|
||||
appName={PLUGIN_ID}
|
||||
indexPatterns={[dataView]}
|
||||
onQuerySubmit={() => {}}
|
||||
isLoading={false}
|
||||
showDatePicker
|
||||
allowSavingQueries
|
||||
showSearchBar
|
||||
dataViewPickerComponentProps={
|
||||
{
|
||||
trigger: {
|
||||
label: dataView?.getName() || '',
|
||||
'data-test-subj': 'discover-dataView-switch-link',
|
||||
title: dataView?.getIndexPattern() || '',
|
||||
},
|
||||
currentDataViewId: dataView?.id,
|
||||
} as DataViewPickerProps
|
||||
}
|
||||
useDefaultBehaviors
|
||||
displayStyle="detached"
|
||||
config={[
|
||||
{
|
||||
id: 'inspect',
|
||||
label: 'Inspect',
|
||||
run: () => {},
|
||||
},
|
||||
{
|
||||
id: 'alerts',
|
||||
label: 'Alerts',
|
||||
run: () => {},
|
||||
},
|
||||
{
|
||||
id: 'open',
|
||||
label: 'Open',
|
||||
iconType: 'folderOpen',
|
||||
iconOnly: true,
|
||||
run: () => {},
|
||||
},
|
||||
{
|
||||
id: 'share',
|
||||
label: 'Share',
|
||||
iconType: 'share',
|
||||
iconOnly: true,
|
||||
run: () => {},
|
||||
},
|
||||
{
|
||||
id: 'save',
|
||||
label: 'Save',
|
||||
emphasize: true,
|
||||
run: () => {},
|
||||
},
|
||||
]}
|
||||
setMenuMountPoint={setHeaderActionMenu}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={true}>
|
||||
<EuiFlexGroup direction="row" alignItems="stretch">
|
||||
<EuiFlexItem
|
||||
grow={false}
|
||||
css={css`
|
||||
background-color: ${euiTheme.colors.body};
|
||||
border-right: ${euiTheme.border.thin};
|
||||
`}
|
||||
>
|
||||
<FieldListSidebar
|
||||
services={services}
|
||||
dataView={dataView}
|
||||
selectedFieldNames={selectedFieldNames}
|
||||
onAddFieldToWorkspace={onAddFieldToWorkspace}
|
||||
onRemoveFieldFromWorkspace={onRemoveFieldFromWorkspace}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<EuiPanel hasShadow={false} paddingSize="l" className="eui-fullHeight">
|
||||
<EuiEmptyPrompt
|
||||
iconType="beaker"
|
||||
title={<h3>{PLUGIN_NAME}</h3>}
|
||||
body={<p>Tab: {label}</p>}
|
||||
/>
|
||||
</EuiPanel>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<EuiEmptyPrompt
|
||||
iconType="warning"
|
||||
color="warning"
|
||||
title={<h2>Make sure to have at least one data view</h2>}
|
||||
body={
|
||||
<p>
|
||||
<IndexPatternSelect
|
||||
placeholder={i18n.translate('searchSessionExample.selectDataViewPlaceholder', {
|
||||
defaultMessage: 'Select data view',
|
||||
})}
|
||||
indexPatternId=""
|
||||
onChange={async (dataViewId?: string) => {
|
||||
if (dataViewId) {
|
||||
const newDataView = await data.dataViews.get(dataViewId);
|
||||
setDataView(newDataView);
|
||||
} else {
|
||||
setDataView(undefined);
|
||||
}
|
||||
}}
|
||||
isClearable={false}
|
||||
data-test-subj="dataViewSelector"
|
||||
/>
|
||||
</p>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</EuiPageBody>
|
||||
</EuiPage>
|
||||
);
|
||||
};
|
91
examples/unified_tabs_examples/public/field_list_sidebar.tsx
Normal file
91
examples/unified_tabs_examples/public/field_list_sidebar.tsx
Normal file
|
@ -0,0 +1,91 @@
|
|||
/*
|
||||
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import React, { useCallback, useMemo, useRef } from 'react';
|
||||
import type { DataView } from '@kbn/data-views-plugin/public';
|
||||
import { generateFilters } from '@kbn/data-plugin/public';
|
||||
import {
|
||||
UnifiedFieldListSidebarContainer,
|
||||
type UnifiedFieldListSidebarContainerProps,
|
||||
type UnifiedFieldListSidebarContainerApi,
|
||||
type AddFieldFilterHandler,
|
||||
} from '@kbn/unified-field-list';
|
||||
import { type CoreStart } from '@kbn/core-lifecycle-browser';
|
||||
import { PLUGIN_ID } from '../common';
|
||||
import { type AppPluginStartDependencies } from './types';
|
||||
|
||||
const getCreationOptions: UnifiedFieldListSidebarContainerProps['getCreationOptions'] = () => {
|
||||
return {
|
||||
originatingApp: PLUGIN_ID,
|
||||
localStorageKeyPrefix: 'examples',
|
||||
timeRangeUpdatesType: 'timefilter',
|
||||
buttonAddFieldVariant: 'toolbar',
|
||||
compressed: true,
|
||||
showSidebarToggleButton: true,
|
||||
disablePopularFields: true,
|
||||
};
|
||||
};
|
||||
|
||||
export interface FieldListSidebarProps {
|
||||
dataView: DataView;
|
||||
selectedFieldNames: string[];
|
||||
services: AppPluginStartDependencies & {
|
||||
core: CoreStart;
|
||||
};
|
||||
onAddFieldToWorkspace: UnifiedFieldListSidebarContainerProps['onAddFieldToWorkspace'];
|
||||
onRemoveFieldFromWorkspace: UnifiedFieldListSidebarContainerProps['onRemoveFieldFromWorkspace'];
|
||||
}
|
||||
|
||||
export const FieldListSidebar: React.FC<FieldListSidebarProps> = ({
|
||||
dataView,
|
||||
selectedFieldNames,
|
||||
services,
|
||||
onAddFieldToWorkspace,
|
||||
onRemoveFieldFromWorkspace,
|
||||
}) => {
|
||||
const unifiedFieldListContainerRef = useRef<UnifiedFieldListSidebarContainerApi>(null);
|
||||
const filterManager = services.data?.query?.filterManager;
|
||||
|
||||
const onAddFilter: AddFieldFilterHandler | undefined = useMemo(
|
||||
() =>
|
||||
filterManager && dataView
|
||||
? (clickedField, values, operation) => {
|
||||
const newFilters = generateFilters(
|
||||
filterManager,
|
||||
clickedField,
|
||||
values,
|
||||
operation,
|
||||
dataView
|
||||
);
|
||||
filterManager.addFilters(newFilters);
|
||||
}
|
||||
: undefined,
|
||||
[dataView, filterManager]
|
||||
);
|
||||
|
||||
const onFieldEdited = useCallback(async () => {
|
||||
unifiedFieldListContainerRef.current?.refetchFieldsExistenceInfo();
|
||||
}, [unifiedFieldListContainerRef]);
|
||||
|
||||
return (
|
||||
<UnifiedFieldListSidebarContainer
|
||||
ref={unifiedFieldListContainerRef}
|
||||
variant="responsive"
|
||||
getCreationOptions={getCreationOptions}
|
||||
services={services}
|
||||
dataView={dataView}
|
||||
allFields={dataView.fields}
|
||||
workspaceSelectedFieldNames={selectedFieldNames}
|
||||
onAddFieldToWorkspace={onAddFieldToWorkspace}
|
||||
onRemoveFieldFromWorkspace={onRemoveFieldFromWorkspace}
|
||||
onAddFilter={onAddFilter}
|
||||
onFieldEdited={onFieldEdited}
|
||||
/>
|
||||
);
|
||||
};
|
17
examples/unified_tabs_examples/public/index.ts
Normal file
17
examples/unified_tabs_examples/public/index.ts
Normal file
|
@ -0,0 +1,17 @@
|
|||
/*
|
||||
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import { UnifiedTabsExamplesPlugin } from './plugin';
|
||||
|
||||
// This exports static code and TypeScript types,
|
||||
// as well as, Kibana Platform `plugin()` initializer.
|
||||
export function plugin() {
|
||||
return new UnifiedTabsExamplesPlugin();
|
||||
}
|
||||
export type { UnifiedTabsExamplesPluginSetup, UnifiedTabsExamplesPluginStart } from './types';
|
72
examples/unified_tabs_examples/public/plugin.ts
Normal file
72
examples/unified_tabs_examples/public/plugin.ts
Normal file
|
@ -0,0 +1,72 @@
|
|||
/*
|
||||
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import { AppMountParameters, CoreSetup, CoreStart, Plugin } from '@kbn/core/public';
|
||||
import {
|
||||
AppPluginSetupDependencies,
|
||||
AppPluginStartDependencies,
|
||||
UnifiedTabsExamplesPluginSetup,
|
||||
UnifiedTabsExamplesPluginStart,
|
||||
} from './types';
|
||||
import { PLUGIN_ID, PLUGIN_NAME } from '../common';
|
||||
import image from './unified_tabs.png';
|
||||
|
||||
export class UnifiedTabsExamplesPlugin
|
||||
implements
|
||||
Plugin<
|
||||
UnifiedTabsExamplesPluginSetup,
|
||||
UnifiedTabsExamplesPluginStart,
|
||||
AppPluginSetupDependencies,
|
||||
AppPluginStartDependencies
|
||||
>
|
||||
{
|
||||
public setup(
|
||||
core: CoreSetup<AppPluginStartDependencies>,
|
||||
{ developerExamples }: AppPluginSetupDependencies
|
||||
): UnifiedTabsExamplesPluginSetup {
|
||||
// Register an application into the side navigation menu
|
||||
core.application.register({
|
||||
id: PLUGIN_ID,
|
||||
title: PLUGIN_NAME,
|
||||
visibleIn: [],
|
||||
mount: async (params: AppMountParameters) => {
|
||||
// Load application bundle
|
||||
const { renderApp } = await import('./application');
|
||||
// Get start services as specified in kibana.json
|
||||
const [coreStart, depsStart] = await core.getStartServices();
|
||||
// Render the application
|
||||
return renderApp(coreStart, depsStart, params);
|
||||
},
|
||||
});
|
||||
|
||||
developerExamples.register({
|
||||
appId: PLUGIN_ID,
|
||||
title: PLUGIN_NAME,
|
||||
description: `Examples of unified tabs functionality.`,
|
||||
image,
|
||||
links: [
|
||||
{
|
||||
label: 'README',
|
||||
href: 'https://github.com/elastic/kibana/tree/main/src/platform/packages/shared/kbn-unified-tabs/README.md',
|
||||
iconType: 'logoGithub',
|
||||
target: '_blank',
|
||||
size: 's',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
public start(core: CoreStart): UnifiedTabsExamplesPluginStart {
|
||||
return {};
|
||||
}
|
||||
|
||||
public stop() {}
|
||||
}
|
38
examples/unified_tabs_examples/public/types.ts
Normal file
38
examples/unified_tabs_examples/public/types.ts
Normal file
|
@ -0,0 +1,38 @@
|
|||
/*
|
||||
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import type { NavigationPublicPluginStart } from '@kbn/navigation-plugin/public';
|
||||
import type { DataPublicPluginStart } from '@kbn/data-plugin/public';
|
||||
import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public';
|
||||
import type { DeveloperExamplesSetup } from '@kbn/developer-examples-plugin/public';
|
||||
import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public';
|
||||
import type { ChartsPluginStart } from '@kbn/charts-plugin/public';
|
||||
import type { FieldFormatsStart } from '@kbn/field-formats-plugin/public';
|
||||
import type { DataViewFieldEditorStart } from '@kbn/data-view-field-editor-plugin/public';
|
||||
import type { UiActionsStart } from '@kbn/ui-actions-plugin/public';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-interface
|
||||
export interface UnifiedTabsExamplesPluginSetup {}
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-interface
|
||||
export interface UnifiedTabsExamplesPluginStart {}
|
||||
|
||||
export interface AppPluginSetupDependencies {
|
||||
developerExamples: DeveloperExamplesSetup;
|
||||
}
|
||||
|
||||
export interface AppPluginStartDependencies {
|
||||
navigation: NavigationPublicPluginStart;
|
||||
data: DataPublicPluginStart;
|
||||
dataViews: DataViewsPublicPluginStart;
|
||||
dataViewFieldEditor: DataViewFieldEditorStart;
|
||||
unifiedSearch: UnifiedSearchPublicPluginStart;
|
||||
charts: ChartsPluginStart;
|
||||
fieldFormats: FieldFormatsStart;
|
||||
uiActions: UiActionsStart;
|
||||
}
|
BIN
examples/unified_tabs_examples/public/unified_tabs.png
Normal file
BIN
examples/unified_tabs_examples/public/unified_tabs.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 253 KiB |
35
examples/unified_tabs_examples/tsconfig.json
Normal file
35
examples/unified_tabs_examples/tsconfig.json
Normal file
|
@ -0,0 +1,35 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "target/types"
|
||||
},
|
||||
"include": [
|
||||
"index.ts",
|
||||
"common/**/*.ts",
|
||||
"public/**/*.ts",
|
||||
"public/**/*.tsx",
|
||||
"../../typings/**/*",
|
||||
],
|
||||
"exclude": [
|
||||
"target/**/*",
|
||||
],
|
||||
"kbn_references": [
|
||||
"@kbn/core",
|
||||
"@kbn/data-plugin",
|
||||
"@kbn/data-views-plugin",
|
||||
"@kbn/navigation-plugin",
|
||||
"@kbn/developer-examples-plugin",
|
||||
"@kbn/unified-search-plugin",
|
||||
"@kbn/i18n-react",
|
||||
"@kbn/i18n",
|
||||
"@kbn/core-lifecycle-browser",
|
||||
"@kbn/charts-plugin",
|
||||
"@kbn/field-formats-plugin",
|
||||
"@kbn/data-view-field-editor-plugin",
|
||||
"@kbn/unified-field-list",
|
||||
"@kbn/ui-actions-plugin",
|
||||
"@kbn/react-kibana-context-theme",
|
||||
"@kbn/unified-tabs",
|
||||
"@kbn/core-application-browser",
|
||||
]
|
||||
}
|
|
@ -981,6 +981,8 @@
|
|||
"@kbn/unified-field-list-examples-plugin": "link:examples/unified_field_list_examples",
|
||||
"@kbn/unified-histogram-plugin": "link:src/platform/plugins/shared/unified_histogram",
|
||||
"@kbn/unified-search-plugin": "link:src/platform/plugins/shared/unified_search",
|
||||
"@kbn/unified-tabs": "link:src/platform/packages/shared/kbn-unified-tabs",
|
||||
"@kbn/unified-tabs-examples-plugin": "link:examples/unified_tabs_examples",
|
||||
"@kbn/unsaved-changes-badge": "link:src/platform/packages/private/kbn-unsaved-changes-badge",
|
||||
"@kbn/unsaved-changes-prompt": "link:src/platform/packages/shared/kbn-unsaved-changes-prompt",
|
||||
"@kbn/upgrade-assistant-plugin": "link:x-pack/platform/plugins/private/upgrade_assistant",
|
||||
|
|
|
@ -75,6 +75,7 @@ export const storybookAliases = {
|
|||
triggers_actions_ui: 'x-pack/platform/plugins/shared/triggers_actions_ui/.storybook',
|
||||
ui_actions_enhanced: 'src/platform/plugins/shared/ui_actions_enhanced/.storybook',
|
||||
unified_search: 'src/platform/plugins/shared/unified_search/.storybook',
|
||||
unified_tabs: 'src/platform/packages/shared/kbn-unified-tabs/.storybook',
|
||||
profiling: 'x-pack/solutions/observability/plugins/profiling/.storybook',
|
||||
event_stacktrace: 'x-pack/platform/packages/shared/kbn-event-stacktrace/.storybook',
|
||||
};
|
||||
|
|
|
@ -0,0 +1,10 @@
|
|||
/*
|
||||
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
module.exports = require('@kbn/storybook').defaultConfig;
|
15
src/platform/packages/shared/kbn-unified-tabs/README.md
Normal file
15
src/platform/packages/shared/kbn-unified-tabs/README.md
Normal file
|
@ -0,0 +1,15 @@
|
|||
# @kbn/unified-tabs
|
||||
|
||||
Tabs bar components.
|
||||
|
||||
## Storybook
|
||||
|
||||
Run the following command:
|
||||
`NODE_OPTIONS="--openssl-legacy-provider" node scripts/storybook unified_tabs`.
|
||||
|
||||
## Example plugin
|
||||
|
||||
Start Kibana with:
|
||||
`yarn start --run-examples`.
|
||||
|
||||
Then navigate to the Unified Tabs example plugin `http://localhost:5601/app/unifiedTabsExamples`.
|
14
src/platform/packages/shared/kbn-unified-tabs/index.ts
Normal file
14
src/platform/packages/shared/kbn-unified-tabs/index.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
/*
|
||||
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
export type { TabItem } from './src/types';
|
||||
export {
|
||||
TabbedContent as UnifiedTabs,
|
||||
type TabbedContentProps as UnifiedTabsProps,
|
||||
} from './src/components/tabbed_content';
|
14
src/platform/packages/shared/kbn-unified-tabs/jest.config.js
Normal file
14
src/platform/packages/shared/kbn-unified-tabs/jest.config.js
Normal file
|
@ -0,0 +1,14 @@
|
|||
/*
|
||||
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
preset: '@kbn/test',
|
||||
rootDir: '../../../../..',
|
||||
roots: ['<rootDir>/src/platform/packages/shared/kbn-unified-tabs'],
|
||||
};
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"type": "shared-browser",
|
||||
"id": "@kbn/unified-tabs",
|
||||
"owner": "@elastic/kibana-data-discovery",
|
||||
"group": "platform",
|
||||
"visibility": "shared"
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"name": "@kbn/unified-tabs",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"license": "Elastic License 2.0 OR AGPL-3.0-only OR SSPL-1.0",
|
||||
"sideEffects": false
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
/*
|
||||
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
export const STORYBOOK_TITLE = 'Unified Tabs';
|
|
@ -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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import type { ComponentStory } from '@storybook/react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { Tab, type TabProps } from '../tab';
|
||||
import { STORYBOOK_TITLE } from './storybook_constants';
|
||||
|
||||
export default {
|
||||
title: `${STORYBOOK_TITLE}/Tab`,
|
||||
parameters: {
|
||||
backgrounds: {
|
||||
default: 'white',
|
||||
values: [{ name: 'white', value: '#fff' }],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const TabTemplate: ComponentStory<React.FC<TabProps>> = (args) => (
|
||||
<Tab {...args} onSelect={action('onSelect')} onClose={action('onClose')} />
|
||||
);
|
||||
|
||||
export const Default = TabTemplate.bind({});
|
||||
Default.args = {
|
||||
item: {
|
||||
id: '1',
|
||||
label: 'Tab 1',
|
||||
},
|
||||
isSelected: false,
|
||||
};
|
||||
|
||||
export const Selected = TabTemplate.bind({});
|
||||
Selected.args = {
|
||||
item: {
|
||||
id: '1',
|
||||
label: 'Tab 1',
|
||||
},
|
||||
isSelected: true,
|
||||
};
|
||||
|
||||
export const WithLongLabel = TabTemplate.bind({});
|
||||
WithLongLabel.args = {
|
||||
item: {
|
||||
id: '1',
|
||||
label: 'Tab with a very long label that should be truncated',
|
||||
},
|
||||
isSelected: false,
|
||||
};
|
||||
|
||||
export const WithLongLabelSelected = TabTemplate.bind({});
|
||||
WithLongLabelSelected.args = {
|
||||
item: {
|
||||
id: '1',
|
||||
label: 'Tab with a very long label that should be truncated',
|
||||
},
|
||||
isSelected: true,
|
||||
};
|
|
@ -0,0 +1,72 @@
|
|||
/*
|
||||
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import type { ComponentStory } from '@storybook/react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { TabbedContent, type TabbedContentProps } from '../tabbed_content';
|
||||
import { STORYBOOK_TITLE } from './storybook_constants';
|
||||
|
||||
let TMP_COUNTER = 0;
|
||||
|
||||
export default {
|
||||
title: `${STORYBOOK_TITLE}/Tabs`,
|
||||
parameters: {
|
||||
backgrounds: {
|
||||
default: 'white',
|
||||
values: [{ name: 'white', value: '#fff' }],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const TabbedContentTemplate: ComponentStory<React.FC<TabbedContentProps>> = (args) => (
|
||||
<TabbedContent
|
||||
{...args}
|
||||
createItem={() => {
|
||||
TMP_COUNTER += 1;
|
||||
return {
|
||||
id: `tab_${TMP_COUNTER}`,
|
||||
label: `Tab ${TMP_COUNTER}`,
|
||||
};
|
||||
}}
|
||||
onChanged={action('onClosed')}
|
||||
renderContent={(item) => (
|
||||
<div style={{ paddingTop: '16px' }}>Content for tab: {item.label}</div>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
export const Default = TabbedContentTemplate.bind({});
|
||||
Default.args = {
|
||||
initialItems: [
|
||||
{
|
||||
id: '1',
|
||||
label: 'Tab 1',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const WithMultipleTabs = TabbedContentTemplate.bind({});
|
||||
WithMultipleTabs.args = {
|
||||
initialItems: [
|
||||
{
|
||||
id: '1',
|
||||
label: 'Tab 1',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
label: 'Tab 2',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
label: 'Tab 3',
|
||||
},
|
||||
],
|
||||
initialSelectedItemId: '3',
|
||||
};
|
|
@ -0,0 +1,10 @@
|
|||
/*
|
||||
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
export { Tab, type TabProps } from './tab';
|
|
@ -0,0 +1,50 @@
|
|||
/*
|
||||
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { Tab } from './tab';
|
||||
|
||||
const tabItem = {
|
||||
id: 'test-id',
|
||||
label: 'test-label',
|
||||
};
|
||||
|
||||
const tabContentId = 'test-content-id';
|
||||
|
||||
describe('Tab', () => {
|
||||
it('renders tab', async () => {
|
||||
const onSelect = jest.fn();
|
||||
const onClose = jest.fn();
|
||||
|
||||
render(
|
||||
<Tab
|
||||
tabContentId={tabContentId}
|
||||
item={tabItem}
|
||||
isSelected={false}
|
||||
onSelect={onSelect}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText(tabItem.label)).toBeInTheDocument();
|
||||
|
||||
const tab = screen.getByRole('tab');
|
||||
expect(tab).toHaveAttribute('id', `tab-${tabItem.id}`);
|
||||
expect(tab).toHaveAttribute('aria-controls', tabContentId);
|
||||
tab.click();
|
||||
expect(onSelect).toHaveBeenCalled();
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
|
||||
const closeButton = screen.getByTestId(`unifiedTabs_closeTabBtn_${tabItem.id}`);
|
||||
closeButton.click();
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
expect(onSelect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,161 @@
|
|||
/*
|
||||
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import React, { MouseEvent, useCallback } from 'react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { css } from '@emotion/react';
|
||||
import {
|
||||
EuiButtonIcon,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiText,
|
||||
EuiThemeComputed,
|
||||
useEuiTheme,
|
||||
} from '@elastic/eui';
|
||||
import { getTabAttributes } from '../../utils/get_tab_attributes';
|
||||
import type { TabItem } from '../../types';
|
||||
|
||||
export interface TabProps {
|
||||
item: TabItem;
|
||||
isSelected: boolean;
|
||||
tabContentId: string;
|
||||
onSelect: (item: TabItem) => void;
|
||||
onClose: (item: TabItem) => void;
|
||||
}
|
||||
|
||||
export const Tab: React.FC<TabProps> = ({ item, isSelected, tabContentId, onSelect, onClose }) => {
|
||||
const { euiTheme } = useEuiTheme();
|
||||
|
||||
const tabContainerDataTestSubj = `unifiedTabs_tab_${item.id}`;
|
||||
const closeButtonLabel = i18n.translate('unifiedTabs.closeTabButton', {
|
||||
defaultMessage: 'Close',
|
||||
});
|
||||
|
||||
const onSelectEvent = useCallback(
|
||||
(event: MouseEvent<HTMLElement>) => {
|
||||
event.stopPropagation();
|
||||
|
||||
if (!isSelected) {
|
||||
onSelect(item);
|
||||
}
|
||||
},
|
||||
[onSelect, item, isSelected]
|
||||
);
|
||||
|
||||
const onCloseEvent = useCallback(
|
||||
(event: MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation();
|
||||
onClose(item);
|
||||
},
|
||||
[onClose, item]
|
||||
);
|
||||
|
||||
const onClickEvent = useCallback(
|
||||
(event: MouseEvent<HTMLDivElement>) => {
|
||||
if (event.currentTarget.getAttribute('data-test-subj') === tabContainerDataTestSubj) {
|
||||
// if user presses on the space around the buttons, we should still trigger the onSelectEvent
|
||||
onSelectEvent(event);
|
||||
}
|
||||
},
|
||||
[onSelectEvent, tabContainerDataTestSubj]
|
||||
);
|
||||
|
||||
return (
|
||||
<EuiFlexGroup
|
||||
alignItems="center"
|
||||
css={getTabContainerCss(euiTheme, isSelected)}
|
||||
data-test-subj={tabContainerDataTestSubj}
|
||||
responsive={false}
|
||||
gutterSize="none"
|
||||
onClick={onClickEvent}
|
||||
>
|
||||
<button
|
||||
{...getTabAttributes(item, tabContentId)}
|
||||
aria-selected={isSelected}
|
||||
css={getTabButtonCss(euiTheme)}
|
||||
className="unifiedTabs__tabBtn"
|
||||
data-test-subj={`unifiedTabs_selectTabBtn_${item.id}`}
|
||||
role="tab"
|
||||
type="button"
|
||||
onClick={onSelectEvent}
|
||||
>
|
||||
<EuiText color="inherit" size="s" className="eui-textTruncate">
|
||||
{item.label}
|
||||
</EuiText>
|
||||
</button>
|
||||
<EuiFlexItem grow={false} className="unifiedTabs__closeTabBtn">
|
||||
<EuiButtonIcon
|
||||
aria-label={closeButtonLabel}
|
||||
title={closeButtonLabel}
|
||||
color="text"
|
||||
data-test-subj={`unifiedTabs_closeTabBtn_${item.id}`}
|
||||
iconType="cross"
|
||||
onClick={onCloseEvent}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
};
|
||||
|
||||
function getTabContainerCss(euiTheme: EuiThemeComputed, isSelected: boolean) {
|
||||
// TODO: remove the usage of deprecated colors
|
||||
|
||||
return css`
|
||||
display: inline-flex;
|
||||
border-right: ${euiTheme.border.thin};
|
||||
border-color: ${euiTheme.colors.lightShade};
|
||||
height: ${euiTheme.size.xl};
|
||||
padding-left: ${euiTheme.size.m};
|
||||
padding-right: ${euiTheme.size.xs};
|
||||
min-width: 96px;
|
||||
max-width: 280px;
|
||||
|
||||
background-color: ${isSelected ? euiTheme.colors.emptyShade : euiTheme.colors.lightestShade};
|
||||
color: ${isSelected ? euiTheme.colors.text : euiTheme.colors.subduedText};
|
||||
transition: background-color ${euiTheme.animation.fast};
|
||||
|
||||
.unifiedTabs__closeTabBtn {
|
||||
opacity: 0;
|
||||
transition: opacity ${euiTheme.animation.fast};
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.unifiedTabs__closeTabBtn {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
${isSelected
|
||||
? `
|
||||
.unifiedTabs__tabBtn {
|
||||
cursor: default;
|
||||
}`
|
||||
: `
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: ${euiTheme.colors.lightShade};
|
||||
color: ${euiTheme.colors.text};
|
||||
}`}
|
||||
`;
|
||||
}
|
||||
|
||||
function getTabButtonCss(euiTheme: EuiThemeComputed) {
|
||||
return css`
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
flex-grow: 1;
|
||||
padding-right: ${euiTheme.size.xs};
|
||||
text-align: left;
|
||||
color: inherit;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
`;
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
/*
|
||||
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
export { TabbedContent, type TabbedContentProps } from './tabbed_content';
|
|
@ -0,0 +1,125 @@
|
|||
/*
|
||||
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { htmlIdGenerator, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
|
||||
import { TabsBar } from '../tabs_bar';
|
||||
import { getTabAttributes } from '../../utils/get_tab_attributes';
|
||||
import { TabItem } from '../../types';
|
||||
|
||||
export interface TabbedContentProps {
|
||||
initialItems: TabItem[];
|
||||
initialSelectedItemId?: string;
|
||||
'data-test-subj'?: string;
|
||||
renderContent: (selectedItem: TabItem) => React.ReactNode;
|
||||
createItem: () => TabItem;
|
||||
onChanged: (state: TabbedContentState) => void;
|
||||
}
|
||||
|
||||
export interface TabbedContentState {
|
||||
items: TabItem[];
|
||||
selectedItem: TabItem | null;
|
||||
}
|
||||
|
||||
export const TabbedContent: React.FC<TabbedContentProps> = ({
|
||||
initialItems,
|
||||
initialSelectedItemId,
|
||||
renderContent,
|
||||
createItem,
|
||||
onChanged,
|
||||
}) => {
|
||||
const [tabContentId] = useState(() => htmlIdGenerator()());
|
||||
const [state, _setState] = useState<TabbedContentState>(() => {
|
||||
return {
|
||||
items: initialItems,
|
||||
selectedItem:
|
||||
(initialSelectedItemId && initialItems.find((item) => item.id === initialSelectedItemId)) ||
|
||||
initialItems[0],
|
||||
};
|
||||
});
|
||||
const { items, selectedItem } = state;
|
||||
|
||||
const changeState = useCallback(
|
||||
(getNextState: (prevState: TabbedContentState) => TabbedContentState) => {
|
||||
_setState((prevState) => {
|
||||
const nextState = getNextState(prevState);
|
||||
onChanged(nextState);
|
||||
return nextState;
|
||||
});
|
||||
},
|
||||
[_setState, onChanged]
|
||||
);
|
||||
|
||||
const onSelect = useCallback(
|
||||
(item: TabItem) => {
|
||||
changeState((prevState) => ({
|
||||
...prevState,
|
||||
selectedItem: item,
|
||||
}));
|
||||
},
|
||||
[changeState]
|
||||
);
|
||||
|
||||
const onClose = useCallback(
|
||||
(item: TabItem) => {
|
||||
changeState((prevState) => {
|
||||
const nextItems = prevState.items.filter((prevItem) => prevItem.id !== item.id);
|
||||
// TODO: better selection logic
|
||||
const nextSelectedItem = nextItems.length ? nextItems[nextItems.length - 1] : null;
|
||||
|
||||
return {
|
||||
items: nextItems,
|
||||
selectedItem:
|
||||
prevState.selectedItem?.id !== item.id ? prevState.selectedItem : nextSelectedItem,
|
||||
};
|
||||
});
|
||||
},
|
||||
[changeState]
|
||||
);
|
||||
|
||||
const onAdd = useCallback(() => {
|
||||
const newItem = createItem();
|
||||
changeState((prevState) => {
|
||||
return {
|
||||
items: [...prevState.items, newItem],
|
||||
selectedItem: newItem,
|
||||
};
|
||||
});
|
||||
}, [changeState, createItem]);
|
||||
|
||||
return (
|
||||
<EuiFlexGroup
|
||||
responsive={false}
|
||||
direction="column"
|
||||
gutterSize="none"
|
||||
className="eui-fullHeight"
|
||||
>
|
||||
<EuiFlexItem grow={false}>
|
||||
<TabsBar
|
||||
items={items}
|
||||
selectedItem={selectedItem}
|
||||
tabContentId={tabContentId}
|
||||
onAdd={onAdd}
|
||||
onSelect={onSelect}
|
||||
onClose={onClose}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
{selectedItem ? (
|
||||
<EuiFlexItem
|
||||
data-test-subj="unifiedTabs_selectedTabContent"
|
||||
role="tabpanel"
|
||||
id={tabContentId}
|
||||
aria-labelledby={getTabAttributes(selectedItem, tabContentId).id}
|
||||
>
|
||||
{renderContent(selectedItem)}
|
||||
</EuiFlexItem>
|
||||
) : null}
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,10 @@
|
|||
/*
|
||||
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
export { TabsBar, type TabsBarProps } from './tabs_bar';
|
|
@ -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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { TabsBar } from './tabs_bar';
|
||||
|
||||
const items = Array.from({ length: 5 }).map((_, i) => ({
|
||||
id: `tab-${i}`,
|
||||
label: `Tab ${i}`,
|
||||
}));
|
||||
|
||||
const tabContentId = 'test-content-id';
|
||||
|
||||
describe('TabsBar', () => {
|
||||
it('renders tabs bar', async () => {
|
||||
const onAdd = jest.fn();
|
||||
const onSelect = jest.fn();
|
||||
const onClose = jest.fn();
|
||||
|
||||
const selectedItem = items[0];
|
||||
|
||||
render(
|
||||
<TabsBar
|
||||
tabContentId={tabContentId}
|
||||
items={items}
|
||||
selectedItem={selectedItem}
|
||||
onAdd={onAdd}
|
||||
onSelect={onSelect}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
|
||||
const tabs = screen.getAllByRole('tab');
|
||||
expect(tabs).toHaveLength(items.length);
|
||||
|
||||
items.forEach((tabItem, index) => {
|
||||
const tab = tabs[index];
|
||||
expect(screen.getByText(tabItem.label)).toBeInTheDocument();
|
||||
expect(tab).toHaveAttribute('id', `tab-${tabItem.id}`);
|
||||
expect(tab).toHaveAttribute('aria-controls', tabContentId);
|
||||
expect(tab).toHaveAttribute(
|
||||
'aria-selected',
|
||||
tabItem.id === selectedItem.id ? 'true' : 'false'
|
||||
);
|
||||
});
|
||||
|
||||
const tab = screen.getByText(items[1].label);
|
||||
tab.click();
|
||||
expect(onSelect).toHaveBeenCalled();
|
||||
|
||||
const addButton = screen.getByTestId('unifiedTabs_tabsBar_newTabBtn');
|
||||
addButton.click();
|
||||
expect(onAdd).toHaveBeenCalled();
|
||||
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,79 @@
|
|||
/*
|
||||
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { css } from '@emotion/react';
|
||||
import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem, useEuiTheme } from '@elastic/eui';
|
||||
import { Tab } from '../tab';
|
||||
import type { TabItem } from '../../types';
|
||||
|
||||
export interface TabsBarProps {
|
||||
items: TabItem[];
|
||||
selectedItem: TabItem | null;
|
||||
tabContentId: string;
|
||||
onAdd: () => void;
|
||||
onSelect: (item: TabItem) => void;
|
||||
onClose: (item: TabItem) => void;
|
||||
}
|
||||
|
||||
export const TabsBar: React.FC<TabsBarProps> = ({
|
||||
items,
|
||||
selectedItem,
|
||||
tabContentId,
|
||||
onAdd,
|
||||
onSelect,
|
||||
onClose,
|
||||
}) => {
|
||||
const { euiTheme } = useEuiTheme();
|
||||
|
||||
const addButtonLabel = i18n.translate('unifiedTabs.createTabButton', {
|
||||
defaultMessage: 'New',
|
||||
});
|
||||
|
||||
return (
|
||||
<EuiFlexGroup
|
||||
role="tablist"
|
||||
data-test-subj="unifiedTabs_tabsBar"
|
||||
responsive={false}
|
||||
alignItems="center"
|
||||
gutterSize="none"
|
||||
className="eui-scrollBar"
|
||||
css={css`
|
||||
background-color: ${euiTheme.colors.lightestShade};
|
||||
overflow-x: auto;
|
||||
`}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<EuiFlexItem key={item.id} grow={false}>
|
||||
<Tab
|
||||
item={item}
|
||||
isSelected={selectedItem?.id === item.id}
|
||||
tabContentId={tabContentId}
|
||||
onSelect={onSelect}
|
||||
onClose={onClose}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
))}
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButtonIcon
|
||||
data-test-subj="unifiedTabs_tabsBar_newTabBtn"
|
||||
iconType="plus"
|
||||
color="text"
|
||||
css={css`
|
||||
margin-inline: ${euiTheme.size.s};
|
||||
`}
|
||||
aria-label={addButtonLabel}
|
||||
title={addButtonLabel}
|
||||
onClick={onAdd}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
};
|
10
src/platform/packages/shared/kbn-unified-tabs/src/index.ts
Normal file
10
src/platform/packages/shared/kbn-unified-tabs/src/index.ts
Normal file
|
@ -0,0 +1,10 @@
|
|||
/*
|
||||
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
export { TabbedContent, type TabbedContentProps } from './components/tabbed_content';
|
13
src/platform/packages/shared/kbn-unified-tabs/src/types.ts
Normal file
13
src/platform/packages/shared/kbn-unified-tabs/src/types.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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
export interface TabItem {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
/*
|
||||
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import { TabItem } from '../types';
|
||||
|
||||
export const getTabAttributes = (item: TabItem, tabContentId: string) => {
|
||||
return {
|
||||
id: `tab-${item.id}`,
|
||||
'aria-controls': tabContentId,
|
||||
};
|
||||
};
|
13
src/platform/packages/shared/kbn-unified-tabs/tsconfig.json
Normal file
13
src/platform/packages/shared/kbn-unified-tabs/tsconfig.json
Normal file
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"extends": "../../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "target/types"
|
||||
},
|
||||
"include": ["**/*.ts", "**/*.tsx"],
|
||||
"exclude": [
|
||||
"target/**/*"
|
||||
],
|
||||
"kbn_references": [
|
||||
"@kbn/i18n",
|
||||
]
|
||||
}
|
|
@ -2024,6 +2024,10 @@
|
|||
"@kbn/unified-histogram-plugin/*": ["src/platform/plugins/shared/unified_histogram/*"],
|
||||
"@kbn/unified-search-plugin": ["src/platform/plugins/shared/unified_search"],
|
||||
"@kbn/unified-search-plugin/*": ["src/platform/plugins/shared/unified_search/*"],
|
||||
"@kbn/unified-tabs": ["src/platform/packages/shared/kbn-unified-tabs"],
|
||||
"@kbn/unified-tabs/*": ["src/platform/packages/shared/kbn-unified-tabs/*"],
|
||||
"@kbn/unified-tabs-examples-plugin": ["examples/unified_tabs_examples"],
|
||||
"@kbn/unified-tabs-examples-plugin/*": ["examples/unified_tabs_examples/*"],
|
||||
"@kbn/unsaved-changes-badge": ["src/platform/packages/private/kbn-unsaved-changes-badge"],
|
||||
"@kbn/unsaved-changes-badge/*": ["src/platform/packages/private/kbn-unsaved-changes-badge/*"],
|
||||
"@kbn/unsaved-changes-prompt": ["src/platform/packages/shared/kbn-unsaved-changes-prompt"],
|
||||
|
|
|
@ -7917,6 +7917,14 @@
|
|||
version "0.0.0"
|
||||
uid ""
|
||||
|
||||
"@kbn/unified-tabs-examples-plugin@link:examples/unified_tabs_examples":
|
||||
version "0.0.0"
|
||||
uid ""
|
||||
|
||||
"@kbn/unified-tabs@link:src/platform/packages/shared/kbn-unified-tabs":
|
||||
version "0.0.0"
|
||||
uid ""
|
||||
|
||||
"@kbn/unsaved-changes-badge@link:src/platform/packages/private/kbn-unsaved-changes-badge":
|
||||
version "0.0.0"
|
||||
uid ""
|
||||
|
|
Loading…
Add table
Reference in a new issue