diff --git a/src/plugins/wizard/public/application/_variables.scss b/src/plugins/wizard/public/application/_variables.scss index c1b3646e8e4..6ffa04d0eb4 100644 --- a/src/plugins/wizard/public/application/_variables.scss +++ b/src/plugins/wizard/public/application/_variables.scss @@ -1,3 +1,5 @@ @import '@elastic/eui/src/global_styling/variables/header'; +@import '@elastic/eui/src/global_styling/variables/form'; -$osdHeaderOffset: $euiHeaderHeightCompensation * 2; \ No newline at end of file +$osdHeaderOffset: $euiHeaderHeightCompensation * 2; +$wizSideNavWidth: 470px; diff --git a/src/plugins/wizard/public/application/app.scss b/src/plugins/wizard/public/application/app.scss index 4f05c6ecd7a..3e2a28aca70 100644 --- a/src/plugins/wizard/public/application/app.scss +++ b/src/plugins/wizard/public/application/app.scss @@ -4,7 +4,7 @@ padding: 0; display: grid; grid-template-rows: min-content 1fr; - grid-template-columns: 470px 1fr; + grid-template-columns: $wizSideNavWidth 1fr; grid-template-areas: "topNav topNav" "sideNav workspace" diff --git a/src/plugins/wizard/public/application/components/data_source_select.tsx b/src/plugins/wizard/public/application/components/data_source_select.tsx new file mode 100644 index 00000000000..3bbae08573b --- /dev/null +++ b/src/plugins/wizard/public/application/components/data_source_select.tsx @@ -0,0 +1,51 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { i18n } from '@osd/i18n'; +import { EuiIcon } from '@elastic/eui'; +import { SearchableDropdown, SearchableDropdownOption } from './searchable_dropdown'; +import { useIndexPatterns } from '../utils/use'; +import indexPatternSvg from '../../assets/index_pattern.svg'; +import { useTypedDispatch } from '../utils/state_management'; +import { setIndexPattern } from '../utils/state_management/visualization_slice'; +import { IndexPattern } from '../../../../data/public'; + +function indexPatternEquality(A?: SearchableDropdownOption, B?: SearchableDropdownOption): boolean { + return !A || !B ? false : A.id === B.id; +} + +function toSearchableDropdownOption(indexPattern: IndexPattern): SearchableDropdownOption { + return { + id: indexPattern.id || '', + label: indexPattern.title, + searchableLabel: indexPattern.title, + prepend: , + }; +} + +export const DataSourceSelect = () => { + const { indexPatterns, loading, error, selected } = useIndexPatterns(); + const dispatch = useTypedDispatch(); + + return ( + { + const foundOption = indexPatterns.filter((s) => s.id === option.id)[0]; + if (foundOption !== undefined && typeof foundOption.id === 'string') { + dispatch(setIndexPattern(foundOption.id)); + } + }} + prepend={i18n.translate('wizard.nav.dataSource.selector.title', { + defaultMessage: 'Data Source', + })} + error={error} + loading={loading} + options={indexPatterns.map(toSearchableDropdownOption)} + equality={indexPatternEquality} + /> + ); +}; diff --git a/src/plugins/wizard/public/application/components/searchable_dropdown.scss b/src/plugins/wizard/public/application/components/searchable_dropdown.scss new file mode 100644 index 00000000000..6de99e91356 --- /dev/null +++ b/src/plugins/wizard/public/application/components/searchable_dropdown.scss @@ -0,0 +1,26 @@ +@import "../variables"; + +.searchableDropdown { + overflow: "hidden"; +} + +.searchableDropdown .euiPopover, +.searchableDropdown .euiPopover__anchor { + width: 100%; +} + +.searchableDropdown--fixedWidthChild { + width: calc(#{$wizSideNavWidth} - #{$euiSizeXL} * 2) ; +} + +.searchableDropdown--topDisplay { + padding-right: $euiSizeL; +} + + +.searchableDropdown--selectableWrapper .euiSelectableList { + // When clicking on the selectable content it will "highlight" itself with a box shadow + // This turns that off + box-shadow: none !important; + margin: ($euiFormControlPadding * -1) - 4; +} diff --git a/src/plugins/wizard/public/application/components/searchable_dropdown.tsx b/src/plugins/wizard/public/application/components/searchable_dropdown.tsx new file mode 100644 index 00000000000..da679533508 --- /dev/null +++ b/src/plugins/wizard/public/application/components/searchable_dropdown.tsx @@ -0,0 +1,170 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useState, useEffect } from 'react'; +import { + EuiLoadingSpinner, + EuiFormControlLayout, + EuiPopoverTitle, + EuiButtonEmpty, + EuiPopover, + EuiSelectable, + EuiTextColor, +} from '@elastic/eui'; +import './searchable_dropdown.scss'; + +export interface SearchableDropdownOption { + id: string; + label: string; + searchableLabel: string; + prepend: any; +} + +interface SearchableDropdownProps { + selected?: SearchableDropdownOption; + onChange: (selection) => void; + options: SearchableDropdownOption[]; + loading: boolean; + error?: Error; + prepend: string; + // not just the first time! + onOpen?: () => void; + equality: (A, B) => boolean; +} + +type DisplayError = any; + +function displayError(error: DisplayError) { + return typeof error === 'object' ? error.toString() : <>{error}; +} + +export const SearchableDropdown = ({ + onChange, + equality, + selected, + options, + error, + loading, + prepend, + onOpen, +}: SearchableDropdownProps) => { + const [localOptions, setLocalOptions] = useState(undefined); + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const onButtonClick = () => { + if (!isPopoverOpen && typeof onOpen === 'function') { + onOpen(); + } + setIsPopoverOpen(!isPopoverOpen); + }; + const closePopover = () => setIsPopoverOpen(false); + + function selectNewOption(newOptions) { + // alright, the EUI Selectable is pretty ratchet + // this is as smarmy as it is because it needs to be + + // first go through and count all the "checked" options + const selectedCount = newOptions.filter((o) => o.checked === 'on').length; + + // if the count is 0, the user just "unchecked" our selection and we can just do nothing + if (selectedCount === 0) { + setIsPopoverOpen(false); + return; + } + + // then, if there's more than two selections, the Selectable left the previous selection as "checked" + // so we need to go and "uncheck" it + for (let i = 0; i < newOptions.length; i++) { + if (equality(newOptions[i], selected) && selectedCount > 1) { + delete newOptions[i].checked; + } + } + + // finally, we can pick the checked option as the actual selection + const newSelection = newOptions.filter((o) => o.checked === 'on')[0]; + + setLocalOptions(newOptions); + setIsPopoverOpen(false); + onChange(newSelection); + } + + useEffect(() => { + setLocalOptions( + options.map((o) => ({ + ...o, + checked: equality(o, selected) ? 'on' : undefined, + })) + ); + }, [selected, options, equality]); + + const listDisplay = (list, search) => + loading ? ( +
+ +
+ ) : error !== undefined ? ( + displayError(error) + ) : ( + <> + + {search} + + {list} + + ); + + const selectable = ( +
+ + {listDisplay} + +
+ ); + + const selectedText = + selected === undefined ? ( + {loading ? 'Loading' : 'Select an option'} + ) : ( + <> + {selected.prepend} {selected.label} + + ); + + const selectedView = ( + + {selectedText} + + ); + + const formControl = {selectedView} + + return ( +
+ +
{selectable}
+
+
+ ); +}; diff --git a/src/plugins/wizard/public/application/components/side_nav.scss b/src/plugins/wizard/public/application/components/side_nav.scss index caa24e30b39..7291a79eb72 100644 --- a/src/plugins/wizard/public/application/components/side_nav.scss +++ b/src/plugins/wizard/public/application/components/side_nav.scss @@ -1,4 +1,5 @@ @import "../util"; +@import "../variables"; .wizSidenav { @include scrollNavParent(auto 1fr); @@ -7,10 +8,6 @@ border-right: $euiBorderThin; } -.wizDatasourceSelector { - padding: $euiSize $euiSize 0 $euiSize; -} - .wizSidenavTabs { .euiTab__content { text-transform: capitalize; @@ -22,3 +19,8 @@ @include scrollNavParent; } } + +.wizDatasourceSelect { + max-width: $wizSideNavWidth; + padding: $euiSize $euiSize 0 $euiSize; +} diff --git a/src/plugins/wizard/public/application/components/side_nav.tsx b/src/plugins/wizard/public/application/components/side_nav.tsx index c74837ceab5..c79f63d7b5c 100644 --- a/src/plugins/wizard/public/application/components/side_nav.tsx +++ b/src/plugins/wizard/public/application/components/side_nav.tsx @@ -3,35 +3,22 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React from 'react'; -import { i18n } from '@osd/i18n'; -import { EuiFormLabel, EuiTabbedContent, EuiTabbedContentTab } from '@elastic/eui'; -import { useOpenSearchDashboards } from '../../../../opensearch_dashboards_react/public'; -import { WizardServices } from '../../types'; +import React, { ReactElement } from 'react'; +import { EuiTabbedContent, EuiTabbedContentTab } from '@elastic/eui'; import './side_nav.scss'; -import { useTypedDispatch, useTypedSelector } from '../utils/state_management'; -import { setIndexPattern } from '../utils/state_management/visualization_slice'; import { useVisualizationType } from '../utils/use'; +import { DataSourceSelect } from './data_source_select'; import { DataTab } from '../contributions'; import { StyleTabConfig } from '../../services/type_service'; export const SideNav = () => { - const { - services: { - data, - savedObjects: { client: savedObjectsClient }, - }, - } = useOpenSearchDashboards(); - const { IndexPatternSelect } = data.ui; - const { indexPattern: indexPatternId } = useTypedSelector((state) => state.visualization); - const dispatch = useTypedDispatch(); const { ui: { containerConfig }, } = useVisualizationType(); const tabs: EuiTabbedContentTab[] = Object.entries(containerConfig).map( ([containerName, config]) => { - let content = null; + let content: null | ReactElement = null; switch (containerName) { case 'data': content = ; @@ -52,27 +39,8 @@ export const SideNav = () => { return (
-
- - {i18n.translate('wizard.nav.dataSource.selector.title', { - defaultMessage: 'Index Pattern', - })} - - { - const newIndexPattern = await data.indexPatterns.get(newIndexPatternId); - - if (newIndexPattern) { - dispatch(setIndexPattern(newIndexPatternId)); - } - }} - isClearable={false} - /> +
+
diff --git a/src/plugins/wizard/public/application/utils/use/index.ts b/src/plugins/wizard/public/application/utils/use/index.ts index 2893ab0d11f..542ae073cde 100644 --- a/src/plugins/wizard/public/application/utils/use/index.ts +++ b/src/plugins/wizard/public/application/utils/use/index.ts @@ -4,4 +4,4 @@ */ export { useVisualizationType } from './use_visualization_type'; -export { useIndexPattern } from './use_index_pattern'; +export { useIndexPattern, useIndexPatterns } from './use_index_pattern'; diff --git a/src/plugins/wizard/public/application/utils/use/use_index_pattern.tsx b/src/plugins/wizard/public/application/utils/use/use_index_pattern.tsx index e02f543d6c1..d5f255fcb10 100644 --- a/src/plugins/wizard/public/application/utils/use/use_index_pattern.tsx +++ b/src/plugins/wizard/public/application/utils/use/use_index_pattern.tsx @@ -28,3 +28,47 @@ export const useIndexPattern = (): IndexPattern | undefined => { return indexPattern; }; + +export const useIndexPatterns = () => { + const { indexPattern: indexId = '' } = useTypedSelector((state) => state.visualization); + const [indexPatterns, setIndexPatterns] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(undefined); + const { + services: { data }, + } = useOpenSearchDashboards(); + + let foundSelected; + if (!loading && !error) { + foundSelected = indexPatterns.filter((p) => p.id === indexId)[0]; + if (foundSelected === undefined) { + setError( + new Error("Attempted to select an index pattern that wasn't in the index pattern list") + ); + } + } + + useEffect(() => { + const handleUpdate = async () => { + try { + const ids = await data.indexPatterns.getIds(indexId); + const patterns = await Promise.all(ids.map((id) => data.indexPatterns.get(id))); + setIndexPatterns(patterns); + } catch (e) { + setError(e); + } finally { + setLoading(false); + } + }; + handleUpdate(); + // we want to run this hook exactly once, which you do by an empty dep array + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return { + indexPatterns, + error, + loading, + selected: foundSelected!, + }; +}; diff --git a/src/plugins/wizard/public/assets/index_pattern.svg b/src/plugins/wizard/public/assets/index_pattern.svg new file mode 100644 index 00000000000..b1f140a3863 --- /dev/null +++ b/src/plugins/wizard/public/assets/index_pattern.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/plugins/wizard/public/types.ts b/src/plugins/wizard/public/types.ts index 14f13d56bdf..529ebc58a60 100644 --- a/src/plugins/wizard/public/types.ts +++ b/src/plugins/wizard/public/types.ts @@ -10,7 +10,7 @@ import { DashboardStart } from 'src/plugins/dashboard/public'; import { VisualizationsSetup } from 'src/plugins/visualizations/public'; import { ExpressionsStart } from 'src/plugins/expressions/public'; import { NavigationPublicPluginStart } from '../../navigation/public'; -import { DataPublicPluginStart } from '../../data/public'; +import { DataPublicPluginStart, IndexPatternField } from '../../data/public'; import { TypeServiceSetup, TypeServiceStart } from './services/type_service'; export type WizardSetup = TypeServiceSetup; diff --git a/src/plugins/wizard/tsconfig.json b/src/plugins/wizard/tsconfig.json new file mode 100644 index 00000000000..ab8821948be --- /dev/null +++ b/src/plugins/wizard/tsconfig.json @@ -0,0 +1,74 @@ +{ + "compilerOptions": { + "baseUrl": "./src/plugins/wizard", + "paths": { + // Allows for importing from `opensearch-dashboards` package for the exported types. + "opensearch-dashboards": ["./opensearch_dashboards"], + "opensearch-dashboards/public": ["src/core/public"], + "opensearch-dashboards/server": ["src/core/server"], + "plugins/*": ["src/legacy/core_plugins/*/public/"], + "test_utils/*": [ + "src/test_utils/public/*" + ], + "fixtures/*": ["src/fixtures/*"], + "@opensearch-project/opensearch": ["node_modules/@opensearch-project/opensearch/api/new"] + }, + // Support .tsx files and transform JSX into calls to React.createElement + "jsx": "react", + // Enables all strict type checking options. + "strict": true, + // save information about the project graph on disk + "incremental": true, + // enables "core language features" + "lib": [ + "esnext", + // includes support for browser APIs + "dom" + ], + // Node 8 should support everything output by esnext, we override this + // in webpack with loader-level compiler options + "target": "esnext", + // Use commonjs for node, overridden in webpack to keep import statements + // to maintain support for things like `await import()` + "module": "commonjs", + // Allows default imports from modules with no default export. This does not affect code emit, just type checking. + // We have to enable this option explicitly since `esModuleInterop` doesn't enable it automatically when ES2015 or + // ESNext module format is used. + "allowSyntheticDefaultImports": true, + // Emits __importStar and __importDefault helpers for runtime babel ecosystem compatibility. + "esModuleInterop": true, + // Resolve modules in the same way as Node.js. Aka make `require` works the + // same in TypeScript as it does in Node.js. + "moduleResolution": "node", + // "resolveJsonModule" allows for importing, extracting types from and generating .json files. + "resolveJsonModule": true, + // Disallow inconsistently-cased references to the same file. + "forceConsistentCasingInFileNames": true, + // Forbid unused local variables as the rule was deprecated by ts-lint + "noUnusedLocals": true, + // Provide full support for iterables in for..of, spread and destructuring when targeting ES5 or ES3. + "downlevelIteration": true, + // import tslib helpers rather than inlining helpers for iteration or spreading, for instance + "importHelpers": true, + // adding global typings + "noImplicitAny": false, + "types": [ + "node", + "jest", + "react", + "flot", + "@testing-library/jest-dom", + "resize-observer-polyfill" + ] + }, + "include": [ + "public/**/*.ts", + "public/**/*.tsx", + "server/**/*.ts", + "../../../typings/**/*", + ], + "exclude": [], + "references": [ + { "path": "../../core/tsconfig.json" } + ] +}