diff --git a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/customize_panel/customize_panel_action.tsx b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/customize_panel/customize_panel_action.tsx index 6146199eee24b..5c3a3a8586354 100644 --- a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/customize_panel/customize_panel_action.tsx +++ b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/customize_panel/customize_panel_action.tsx @@ -79,9 +79,15 @@ export class CustomizePanelAction implements Action (embeddable as VisualizeEmbeddable).getOutput().visTypeName === 'markdown'; const isImage = embeddable.type === 'image'; + const isNavigation = embeddable.type === 'navigation'; return Boolean( - embeddable && hasTimeRange(embeddable) && !isInputControl && !isMarkdown && !isImage + embeddable && + hasTimeRange(embeddable) && + !isInputControl && + !isMarkdown && + !isImage && + !isNavigation ); } diff --git a/src/plugins/navigation_embeddable/public/_mixins.scss b/src/plugins/navigation_embeddable/public/_mixins.scss index 47249b0aa42d4..f327bc1fe73d7 100644 --- a/src/plugins/navigation_embeddable/public/_mixins.scss +++ b/src/plugins/navigation_embeddable/public/_mixins.scss @@ -1,6 +1,6 @@ @import '../../../core/public/mixins'; -@keyframes euiFlyoutAnimation { +@keyframes euiFlyoutOpenAnimation { 0% { opacity: 0; transform: translateX(100%); @@ -12,14 +12,31 @@ } } +@keyframes euiFlyoutCloseAnimation { + 0% { + opacity: 1; + transform: translateX(0%); + } + + 100% { + opacity: 0; + transform: translateX(100%); + } +} + @mixin euiFlyout { @include kibanaFullBodyHeight(); border-left: $euiBorderThin; position: fixed; - width: 50%; z-index: $euiZFlyout; background: $euiColorEmptyShade; display: flex; flex-direction: column; align-items: stretch; + inline-size: 50vw; + + @media only screen and (max-width: 767px) { + inline-size: $euiSizeXL * 13; // 424px + max-inline-size: 90vw; + } } \ No newline at end of file diff --git a/src/plugins/navigation_embeddable/public/components/dashboard_link/dashboard_link_component.tsx b/src/plugins/navigation_embeddable/public/components/dashboard_link/dashboard_link_component.tsx index 7c8170674d9ea..db371c426ed4d 100644 --- a/src/plugins/navigation_embeddable/public/components/dashboard_link/dashboard_link_component.tsx +++ b/src/plugins/navigation_embeddable/public/components/dashboard_link/dashboard_link_component.tsx @@ -29,7 +29,13 @@ export const DashboardLinkComponent = ({ link }: { link: NavigationEmbeddableLin const { loading: loadingDestinationDashboard, value: destinationDashboard } = useAsync(async () => { - return await fetchDashboard(link.destination); + if (!link.label && link.id !== parentDashboardId) { + /** + * only fetch the dashboard if **absolutely** necessary; i.e. only if the dashboard link doesn't have + * some custom label, and if it's not the current dashboard (if it is, use `dashboardContainer` instead) + */ + return await fetchDashboard(link.destination); + } }, [link, parentDashboardId]); return ( diff --git a/src/plugins/navigation_embeddable/public/components/dashboard_link/dashboard_link_destination_picker.tsx b/src/plugins/navigation_embeddable/public/components/dashboard_link/dashboard_link_destination_picker.tsx index 715434dc2c80f..7156449be366d 100644 --- a/src/plugins/navigation_embeddable/public/components/dashboard_link/dashboard_link_destination_picker.tsx +++ b/src/plugins/navigation_embeddable/public/components/dashboard_link/dashboard_link_destination_picker.tsx @@ -8,61 +8,68 @@ import { debounce } from 'lodash'; import useAsync from 'react-use/lib/useAsync'; -import React, { useEffect, useMemo, useState } from 'react'; +import useMount from 'react-use/lib/useMount'; +import React, { useCallback, useMemo, useState } from 'react'; import { EuiBadge, - EuiSpacer, + EuiComboBox, + EuiFlexItem, EuiHighlight, - EuiSelectable, - EuiFieldSearch, - EuiSelectableOption, + EuiFlexGroup, + EuiComboBoxOptionOption, } from '@elastic/eui'; import { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_container'; + import { DashboardItem } from '../../embeddable/types'; -import { memoizedFetchDashboards } from './dashboard_link_tools'; +import { memoizedFetchDashboard, memoizedFetchDashboards } from './dashboard_link_tools'; import { DashboardLinkEmbeddableStrings } from './dashboard_link_strings'; +type DashboardComboBoxOption = EuiComboBoxOptionOption; + export const DashboardLinkDestinationPicker = ({ - setDestination, - setPlaceholder, - currentDestination, + onDestinationPicked, + initialSelection, parentDashboard, ...other }: { - setDestination: (destination?: string) => void; - setPlaceholder: (placeholder?: string) => void; - currentDestination?: string; + onDestinationPicked: (selectedDashboard?: DashboardItem) => void; parentDashboard?: DashboardContainer; + initialSelection?: string; }) => { const [searchString, setSearchString] = useState(''); - const [selectedDashboard, setSelectedDashboard] = useState(); - const [dashboardListOptions, setDashboardListOptions] = useState([]); + const [selectedOption, setSelectedOption] = useState([]); const parentDashboardId = parentDashboard?.select((state) => state.componentState.lastSavedId); - const { loading: loadingDashboardList, value: dashboardList } = useAsync(async () => { - return await memoizedFetchDashboards(searchString, undefined, parentDashboardId); - }, [searchString, parentDashboardId]); + const getDashboardItem = useCallback((dashboard: DashboardItem) => { + return { + key: dashboard.id, + value: dashboard, + label: dashboard.attributes.title, + className: 'navEmbeddableDashboardItem', + }; + }, []); - useEffect(() => { - const dashboardOptions = - (dashboardList ?? []).map((dashboard: DashboardItem) => { - return { - data: dashboard, - label: dashboard.attributes.title, - ...(dashboard.id === parentDashboardId - ? { - prepend: ( - {DashboardLinkEmbeddableStrings.getCurrentDashboardLabel()} - ), - } - : {}), - } as EuiSelectableOption; - }) ?? []; + useMount(async () => { + if (initialSelection) { + const dashboard = await memoizedFetchDashboard(initialSelection); + onDestinationPicked(dashboard); + setSelectedOption([getDashboardItem(dashboard)]); + } + }); - setDashboardListOptions(dashboardOptions); - }, [dashboardList, parentDashboardId, searchString]); + const { loading: loadingDashboardList, value: dashboardList } = useAsync(async () => { + const dashboards = await memoizedFetchDashboards({ + search: searchString, + parentDashboardId, + selectedDashboardId: initialSelection, + }); + const dashboardOptions = (dashboards ?? []).map((dashboard: DashboardItem) => { + return getDashboardItem(dashboard); + }); + return dashboardOptions; + }, [searchString, parentDashboardId, getDashboardItem]); const debouncedSetSearch = useMemo( () => @@ -72,47 +79,53 @@ export const DashboardLinkDestinationPicker = ({ [setSearchString] ); - useEffect(() => { - if (selectedDashboard) { - setDestination(selectedDashboard.id); - setPlaceholder(selectedDashboard.attributes.title); - } else { - setDestination(undefined); - setPlaceholder(undefined); - } - }, [selectedDashboard, setDestination, setPlaceholder]); + const renderOption = useCallback( + (option, searchValue, contentClassName) => { + const { label, key: dashboardId } = option; + return ( + + {dashboardId === parentDashboardId && ( + + {DashboardLinkEmbeddableStrings.getCurrentDashboardLabel()} + + )} + + + {label} + + + + ); + }, + [parentDashboardId] + ); - /* {...other} is needed so all inner elements are treated as part of the form */ + /* {...other} is needed so the EuiComboBox is treated as part of the form */ return ( -
- { - debouncedSetSearch(e.target.value); - }} - /> - - { - if (selected.checked) { - setSelectedDashboard(selected.data as DashboardItem); - } else { - setSelectedDashboard(undefined); - } - setDashboardListOptions(newOptions); - }} - renderOption={(option) => { - return {option.label}; - }} - > - {(list) => list} - -
+ { + debouncedSetSearch(searchValue); + }} + renderOption={renderOption} + selectedOptions={selectedOption} + onChange={(option) => { + setSelectedOption(option); + if (option.length > 0) { + // single select is `true`, so there is only ever one item in the array + onDestinationPicked(option[0].value); + } else { + onDestinationPicked(undefined); + } + }} + /> ); }; diff --git a/src/plugins/navigation_embeddable/public/components/dashboard_link/dashboard_link_strings.ts b/src/plugins/navigation_embeddable/public/components/dashboard_link/dashboard_link_strings.ts index 9bc2e2d40f0b0..c763b0bd88e4e 100644 --- a/src/plugins/navigation_embeddable/public/components/dashboard_link/dashboard_link_strings.ts +++ b/src/plugins/navigation_embeddable/public/components/dashboard_link/dashboard_link_strings.ts @@ -17,8 +17,8 @@ export const DashboardLinkEmbeddableStrings = { i18n.translate('navigationEmbeddable.dsahboardLink.description', { defaultMessage: 'Go to dashboard', }), - getSearchPlaceholder: () => - i18n.translate('navigationEmbeddable.dashboardLink.editor.searchPlaceholder', { + getDashboardPickerPlaceholder: () => + i18n.translate('navigationEmbeddable.dashboardLink.editor.dashboardComboBoxPlaceholder', { defaultMessage: 'Search for a dashboard', }), getDashboardPickerAriaLabel: () => diff --git a/src/plugins/navigation_embeddable/public/components/dashboard_link/dashboard_link_tools.tsx b/src/plugins/navigation_embeddable/public/components/dashboard_link/dashboard_link_tools.tsx index 3735e5a044ffa..9590df2bd6c0d 100644 --- a/src/plugins/navigation_embeddable/public/components/dashboard_link/dashboard_link_tools.tsx +++ b/src/plugins/navigation_embeddable/public/components/dashboard_link/dashboard_link_tools.tsx @@ -6,24 +6,16 @@ * Side Public License, v 1. */ -import { isEmpty, memoize } from 'lodash'; +import { isEmpty, memoize, filter } from 'lodash'; import { DashboardItem } from '../../embeddable/types'; import { dashboardServices } from '../../services/kibana_services'; /** - * Memoized fetch dashboard will only refetch the dashboard information if the given `dashboardId` changed between - * calls; otherwise, it will use the cached dashboard, which may not take into account changes to the dashboard's title - * description, etc. Be mindful when choosing the memoized version. + * ---------------------------------- + * Fetch a single dashboard + * ---------------------------------- */ -export const memoizedFetchDashboard = memoize( - async (dashboardId: string) => { - return await fetchDashboard(dashboardId); - }, - (dashboardId) => { - return dashboardId; - } -); export const fetchDashboard = async (dashboardId: string): Promise => { const findDashboardsService = await dashboardServices.findDashboardsService(); @@ -34,20 +26,39 @@ export const fetchDashboard = async (dashboardId: string): Promise { - return await fetchDashboards(search, size, currentDashboardId); +/** + * Memoized fetch dashboard will only refetch the dashboard information if the given `dashboardId` changed between + * calls; otherwise, it will use the cached dashboard, which may not take into account changes to the dashboard's title + * description, etc. Be mindful when choosing the memoized version. + */ +export const memoizedFetchDashboard = memoize( + async (dashboardId: string) => { + return await fetchDashboard(dashboardId); }, - (search, size, currentDashboardId) => { - return [search, size, currentDashboardId].join('|'); + (dashboardId) => { + return dashboardId; } ); -const fetchDashboards = async ( - search: string = '', - size: number = 10, - currentDashboardId?: string -): Promise => { +/** + * ---------------------------------- + * Fetch lists of dashboards + * ---------------------------------- + */ + +interface FetchDashboardsProps { + size?: number; + search?: string; + parentDashboardId?: string; + selectedDashboardId?: string; +} + +const fetchDashboards = async ({ + search = '', + size = 10, + parentDashboardId, + selectedDashboardId, +}: FetchDashboardsProps): Promise => { const findDashboardsService = await dashboardServices.findDashboardsService(); const responses = await findDashboardsService.search({ search, @@ -55,28 +66,22 @@ const fetchDashboards = async ( options: { onlyTitle: true }, }); - let currentDashboard: DashboardItem | undefined; let dashboardList: DashboardItem[] = responses.hits; - /** When the parent dashboard has been saved (i.e. it has an ID) and there is no search string ... */ - if (currentDashboardId && isEmpty(search)) { - /** ...force the current dashboard (if it is present in the original search results) to the top of the list */ - dashboardList = dashboardList.sort((dashboard) => { - const isCurrentDashboard = dashboard.id === currentDashboardId; - if (isCurrentDashboard) { - currentDashboard = dashboard; - } - return isCurrentDashboard ? -1 : 1; + /** If there is no search string... */ + if (isEmpty(search)) { + /** ... filter out both the parent and selected dashboard from the list ... */ + dashboardList = filter(dashboardList, (dash) => { + return dash.id !== parentDashboardId && dash.id !== selectedDashboardId; }); - /** - * If the current dashboard wasn't returned in the original search, perform another search to find it and - * force it to the front of the list - */ - if (!currentDashboard) { - currentDashboard = await fetchDashboard(currentDashboardId); - dashboardList.pop(); // the result should still be of `size,` so remove the dashboard at the end of the list - dashboardList.unshift(currentDashboard); // in order to force the current dashboard to the start of the list + /** ... so that we can force them to the top of the list as necessary. */ + if (parentDashboardId) { + dashboardList.unshift(await fetchDashboard(parentDashboardId)); + } + + if (selectedDashboardId && selectedDashboardId !== parentDashboardId) { + dashboardList.unshift(await fetchDashboard(selectedDashboardId)); } } @@ -87,3 +92,17 @@ const fetchDashboards = async ( return simplifiedDashboardList; }; + +export const memoizedFetchDashboards = memoize( + async ({ search, size, parentDashboardId, selectedDashboardId }: FetchDashboardsProps) => { + return await fetchDashboards({ + search, + size, + parentDashboardId, + selectedDashboardId, + }); + }, + ({ search, size, parentDashboardId, selectedDashboardId }) => { + return [search, size, parentDashboardId, selectedDashboardId].join('|'); + } +); diff --git a/src/plugins/navigation_embeddable/public/components/external_link/external_link_destination_picker.tsx b/src/plugins/navigation_embeddable/public/components/external_link/external_link_destination_picker.tsx index 596ce183d696b..4119cc32f32aa 100644 --- a/src/plugins/navigation_embeddable/public/components/external_link/external_link_destination_picker.tsx +++ b/src/plugins/navigation_embeddable/public/components/external_link/external_link_destination_picker.tsx @@ -6,7 +6,9 @@ * Side Public License, v 1. */ +import useMount from 'react-use/lib/useMount'; import React, { useState } from 'react'; + import { EuiFieldText } from '@elastic/eui'; import { ExternalLinkEmbeddableStrings } from './external_link_strings'; @@ -15,29 +17,38 @@ const isValidUrl = /^https?:\/\/(?:www.)?[-a-zA-Z0-9@:%._+~#=]{1,256}.[a-zA-Z0-9()]{1,6}\b(?:[-a-zA-Z0-9()@:%_+.~#?&/=]*)$/; export const ExternalLinkDestinationPicker = ({ - setDestination, - setPlaceholder, - currentDestination, + onDestinationPicked, + initialSelection, ...other }: { - setDestination: (destination?: string) => void; - setPlaceholder: (placeholder?: string) => void; - currentDestination?: string; + onDestinationPicked: (destination?: string) => void; + initialSelection?: string; }) => { const [validUrl, setValidUrl] = useState(true); + const [currentUrl, setCurrentUrl] = useState(initialSelection ?? ''); + + useMount(() => { + if (initialSelection) { + onDestinationPicked(initialSelection); + setValidUrl(isValidUrl.test(initialSelection)); + } + }); /* {...other} is needed so all inner elements are treated as part of the form */ return (
{ const url = e.target.value; const isValid = isValidUrl.test(url); setValidUrl(isValid); - setDestination(isValid ? url : undefined); - setPlaceholder(isValid ? url : undefined); + setCurrentUrl(url); + if (isValid) { + onDestinationPicked(url); + } }} />
diff --git a/src/plugins/navigation_embeddable/public/components/navigation_embeddable.scss b/src/plugins/navigation_embeddable/public/components/navigation_embeddable.scss index 214cbbc7a8760..e7a6e5a1890a0 100644 --- a/src/plugins/navigation_embeddable/public/components/navigation_embeddable.scss +++ b/src/plugins/navigation_embeddable/public/components/navigation_embeddable.scss @@ -1,23 +1,68 @@ @import '../mixins'; -.navEmbeddableLinkEditor { - @include euiFlyout; - animation: euiFlyoutAnimation $euiAnimSpeedNormal $euiAnimSlightResistance; +.navEmbeddablePanelEditor { + max-inline-size: $euiSizeXXL * 18; // 40px * 18 = 720px + + .navEmbeddableLinkEditor { + @include euiFlyout; + max-inline-size: $euiSizeXXL * 18; + + &.in { + animation: euiFlyoutOpenAnimation $euiAnimSpeedNormal $euiAnimSlightResistance; + } + + &.out { + animation: euiFlyoutCloseAnimation $euiAnimSpeedNormal $euiAnimSlightResistance; + } - .linkEditorBackButton { - height: auto; + .linkEditorBackButton { + height: auto; + } } } -.navEmbeddablePanelEditor { - .linkText { - flex: 1; - min-width: 0; - - .wrapText { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; +.navEmbeddableDashboardItem { + .euiBadge { + cursor: pointer !important; + } + + // in order to ensure that the "Current" badge doesn't recieve an underline on hover, we have to set the + // text-decoration to `none` for the entire list item and manually set the underline **only** on the text + &:hover { + text-decoration: none; + } + + .navEmbeddableLinkText { + &:hover { + text-decoration: underline !important; + } + } +} + +.navEmbeddableLinkText { + flex: 1; + min-width: 0; + + .wrapText { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } +} + +.navEmbeddableLinkPanel { + padding: $euiSizeXS $euiSizeS; + + .navEmbeddable_hoverActions { + opacity: 0; + visibility: hidden; + transition: visibility $euiAnimSpeedNormal, opacity $euiAnimSpeedNormal; + } + + &:hover, &:focus-within { + .navEmbeddable_hoverActions { + opacity: 1; + visibility: visible; } } } \ No newline at end of file diff --git a/src/plugins/navigation_embeddable/public/components/navigation_embeddable_component.tsx b/src/plugins/navigation_embeddable/public/components/navigation_embeddable_component.tsx index abcbe098f3896..b17be812accbc 100644 --- a/src/plugins/navigation_embeddable/public/components/navigation_embeddable_component.tsx +++ b/src/plugins/navigation_embeddable/public/components/navigation_embeddable_component.tsx @@ -6,35 +6,39 @@ * Side Public License, v 1. */ -import React from 'react'; +import React, { useMemo } from 'react'; import { EuiPanel } from '@elastic/eui'; import { DASHBOARD_LINK_TYPE } from '../embeddable/types'; import { useNavigationEmbeddable } from '../embeddable/navigation_embeddable'; -import { DashboardLinkComponent } from './dashboard_link/dashboard_link_component'; import { ExternalLinkComponent } from './external_link/external_link_component'; +import { DashboardLinkComponent } from './dashboard_link/dashboard_link_component'; +import { memoizedGetOrderedLinkList } from '../editor/navigation_embeddable_editor_tools'; export const NavigationEmbeddableComponent = () => { const navEmbeddable = useNavigationEmbeddable(); const links = navEmbeddable.select((state) => state.explicitInput.links); + const orderedLinks = useMemo(() => { + return memoizedGetOrderedLinkList(links); + }, [links]); /** TODO: Render this as a list **or** "tabs" as part of https://github.com/elastic/kibana/issues/154357 */ return ( - {Object.keys(links).map((linkId) => { + {orderedLinks.map((link) => { return ( - {links[linkId].type === DASHBOARD_LINK_TYPE ? ( - + {link.type === DASHBOARD_LINK_TYPE ? ( + ) : ( - + )} ); diff --git a/src/plugins/navigation_embeddable/public/components/navigation_embeddable_link_editor.tsx b/src/plugins/navigation_embeddable/public/components/navigation_embeddable_link_editor.tsx index a0753130bcc83..1d5fa98766051 100644 --- a/src/plugins/navigation_embeddable/public/components/navigation_embeddable_link_editor.tsx +++ b/src/plugins/navigation_embeddable/public/components/navigation_embeddable_link_editor.tsx @@ -6,7 +6,8 @@ * Side Public License, v 1. */ -import React, { useMemo, useState } from 'react'; +import { v4 as uuidv4 } from 'uuid'; +import React, { useCallback, useMemo, useState } from 'react'; import { EuiForm, @@ -33,24 +34,30 @@ import { EXTERNAL_LINK_TYPE, DASHBOARD_LINK_TYPE, NavigationEmbeddableLink, + DashboardItem, } from '../embeddable/types'; import { NavEmbeddableStrings } from './navigation_embeddable_strings'; +import { NavigationEmbeddableUnorderedLink } from '../editor/open_link_editor_flyout'; import { ExternalLinkDestinationPicker } from './external_link/external_link_destination_picker'; import { DashboardLinkDestinationPicker } from './dashboard_link/dashboard_link_destination_picker'; export const NavigationEmbeddableLinkEditor = ({ + link, onSave, onClose, parentDashboard, }: { onClose: () => void; parentDashboard?: DashboardContainer; - onSave: (newLink: NavigationEmbeddableLink) => void; + link?: NavigationEmbeddableUnorderedLink; // will only be defined if **editing** a link; otherwise, creating a new link + onSave: (newLink: Omit) => void; }) => { - const [selectedLinkType, setSelectedLinkType] = useState(DASHBOARD_LINK_TYPE); - const [linkLabel, setLinkLabel] = useState(''); - const [linkDestination, setLinkDestination] = useState(); - const [linkLabelPlaceholder, setLinkLabelPlaceholder] = useState(); + const [selectedLinkType, setSelectedLinkType] = useState( + link?.type ?? DASHBOARD_LINK_TYPE + ); + const [defaultLinkLabel, setDefaultLinkLabel] = useState(); + const [currentLinkLabel, setCurrentLinkLabel] = useState(link?.label ?? ''); + const [linkDestination, setLinkDestination] = useState(link?.destination); const linkTypes: EuiRadioGroupOption[] = useMemo(() => { return ([DASHBOARD_LINK_TYPE, EXTERNAL_LINK_TYPE] as NavigationLinkType[]).map((type) => { @@ -72,8 +79,35 @@ export const NavigationEmbeddableLinkEditor = ({ }); }, []); + const onDashboardSelected = useCallback( + (selectedDashboard?: DashboardItem) => { + setLinkDestination(selectedDashboard?.id); + if (selectedDashboard) { + const dashboardTitle = selectedDashboard.attributes.title; + setDefaultLinkLabel(dashboardTitle); + if (!currentLinkLabel || currentLinkLabel === defaultLinkLabel) { + setCurrentLinkLabel(dashboardTitle); + } + } + }, + [currentLinkLabel, defaultLinkLabel] + ); + + const onUrlSelected = useCallback( + (url?: string) => { + setLinkDestination(url); + if (url) { + setDefaultLinkLabel(url); + if (!currentLinkLabel || currentLinkLabel === defaultLinkLabel) { + setCurrentLinkLabel(url); + } + } + }, + [currentLinkLabel, defaultLinkLabel] + ); + return ( - + -

{NavEmbeddableStrings.editor.getAddButtonLabel()}

+

+ {link + ? NavEmbeddableStrings.editor.getEditLinkTitle() + : NavEmbeddableStrings.editor.getAddButtonLabel()} +

@@ -97,8 +135,14 @@ export const NavigationEmbeddableLinkEditor = ({ options={linkTypes} idSelected={selectedLinkType} onChange={(id) => { - setLinkDestination(undefined); - setLinkLabelPlaceholder(undefined); + if (link?.type === id) { + setLinkDestination(link.destination); + setCurrentLinkLabel(link.label ?? ''); + } else { + setLinkDestination(undefined); + setCurrentLinkLabel(''); + } + setDefaultLinkLabel(undefined); setSelectedLinkType(id as NavigationLinkType); }} /> @@ -108,15 +152,13 @@ export const NavigationEmbeddableLinkEditor = ({ {selectedLinkType === DASHBOARD_LINK_TYPE ? ( ) : ( )} @@ -124,13 +166,11 @@ export const NavigationEmbeddableLinkEditor = ({ { - setLinkLabel(e.target.value); - }} + value={linkDestination ? currentLinkLabel : ''} + onChange={(e) => setCurrentLinkLabel(e.target.value)} /> @@ -157,15 +197,19 @@ export const NavigationEmbeddableLinkEditor = ({ // this check should always be true, since the button is disabled otherwise - this is just for type safety if (linkDestination) { onSave({ - destination: linkDestination, - label: linkLabel, + label: currentLinkLabel === defaultLinkLabel ? undefined : currentLinkLabel, type: selectedLinkType, + id: link?.id ?? uuidv4(), + destination: linkDestination, }); + onClose(); } }} > - {NavEmbeddableStrings.editor.getAddButtonLabel()} + {link + ? NavEmbeddableStrings.editor.getUpdateButtonLabel() + : NavEmbeddableStrings.editor.getAddButtonLabel()} diff --git a/src/plugins/navigation_embeddable/public/components/navigation_embeddable_panel_editor.tsx b/src/plugins/navigation_embeddable/public/components/navigation_embeddable_panel_editor.tsx index 1c117048fcb23..f4c2ce6b51492 100644 --- a/src/plugins/navigation_embeddable/public/components/navigation_embeddable_panel_editor.tsx +++ b/src/plugins/navigation_embeddable/public/components/navigation_embeddable_panel_editor.tsx @@ -6,103 +6,164 @@ * Side Public License, v 1. */ -import { isEmpty } from 'lodash'; -import { v4 as uuidv4 } from 'uuid'; -import React, { useState } from 'react'; -import useAsync from 'react-use/lib/useAsync'; import useObservable from 'react-use/lib/useObservable'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; + import { EuiText, - EuiIcon, EuiForm, + EuiImage, EuiTitle, EuiPanel, - IconType, EuiSpacer, EuiButton, + EuiToolTip, EuiFormRow, EuiFlexItem, EuiFlexGroup, + EuiDroppable, + EuiDraggable, EuiFlyoutBody, + EuiEmptyPrompt, EuiButtonEmpty, EuiFlyoutFooter, EuiFlyoutHeader, - EuiImage, - EuiEmptyPrompt, + EuiDragDropContext, + euiDragDropReorder, } from '@elastic/eui'; import { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_container'; + import { coreServices } from '../services/kibana_services'; import { - DASHBOARD_LINK_TYPE, - EXTERNAL_LINK_TYPE, - NavigationEmbeddableInput, NavigationEmbeddableLink, - NavigationLinkInfo, + NavigationEmbeddableInput, + NavigationEmbeddableLinkList, } from '../embeddable/types'; import { NavEmbeddableStrings } from './navigation_embeddable_strings'; -import { memoizedFetchDashboard } from './dashboard_link/dashboard_link_tools'; -import { NavigationEmbeddableLinkEditor } from './navigation_embeddable_link_editor'; + +import { openLinkEditorFlyout } from '../editor/open_link_editor_flyout'; +import { memoizedGetOrderedLinkList } from '../editor/navigation_embeddable_editor_tools'; +import { NavigationEmbeddablePanelEditorLink } from './navigation_embeddable_panel_editor_link'; + import noLinksIllustrationDark from '../assets/empty_links_dark.svg'; import noLinksIllustrationLight from '../assets/empty_links_light.svg'; import './navigation_embeddable.scss'; -export const NavigationEmbeddablePanelEditor = ({ +const NavigationEmbeddablePanelEditor = ({ onSave, onClose, initialInput, parentDashboard, }: { onClose: () => void; + parentDashboard?: DashboardContainer; initialInput: Partial; onSave: (input: Partial) => void; - parentDashboard?: DashboardContainer; }) => { - const [showLinkEditorFlyout, setShowLinkEditorFlyout] = useState(false); - const [links, setLinks] = useState(initialInput.links); const isDarkTheme = useObservable(coreServices.theme.theme$)?.darkMode; + const editLinkFlyoutRef: React.RefObject = useMemo(() => React.createRef(), []); + + const [orderedLinks, setOrderedLinks] = useState([]); + + useEffect(() => { + const { links: initialLinks } = initialInput; + if (!initialLinks) { + setOrderedLinks([]); + return; + } + setOrderedLinks(memoizedGetOrderedLinkList(initialLinks)); + }, [initialInput]); - /** - * TODO: There is probably a more efficient way of storing the dashboard information "temporarily" for any new - * panels and only fetching the dashboard saved objects when first loading this flyout. - * - * Will need to think this through and fix as part of the editing process - not worth holding this PR, since it's - * blocking so much other work :) - */ - const { value: linkList } = useAsync(async () => { - if (!links || isEmpty(links)) return []; - - const newLinks: Array<{ id: string; icon: IconType; label: string }> = await Promise.all( - Object.keys(links).map(async (panelId) => { - let label = links[panelId].label; - let icon = NavigationLinkInfo[EXTERNAL_LINK_TYPE].icon; - - if (links[panelId].type === DASHBOARD_LINK_TYPE) { - icon = NavigationLinkInfo[DASHBOARD_LINK_TYPE].icon; - if (!label) { - const dashboard = await memoizedFetchDashboard(links[panelId].destination); - label = dashboard.attributes.title; - } + const onDragEnd = useCallback( + ({ source, destination }) => { + if (source && destination) { + const newList = euiDragDropReorder(orderedLinks, source.index, destination.index); + setOrderedLinks(newList); + } + }, + [orderedLinks] + ); + + const addOrEditLink = useCallback( + async (linkToEdit?: NavigationEmbeddableLink) => { + const newLink = await openLinkEditorFlyout({ + parentDashboard, + link: linkToEdit, + ref: editLinkFlyoutRef, + }); + if (newLink) { + if (linkToEdit) { + setOrderedLinks( + orderedLinks.map((link) => { + if (link.id === linkToEdit.id) { + return { ...newLink, order: linkToEdit.order }; + } + return link; + }) + ); + } else { + setOrderedLinks([...orderedLinks, { ...newLink, order: orderedLinks.length }]); } + } + }, + [editLinkFlyoutRef, orderedLinks, parentDashboard] + ); - return { id: panelId, label: label || links[panelId].destination, icon }; - }) + const deleteLink = useCallback( + (linkId: string) => { + setOrderedLinks( + orderedLinks.filter((link) => { + return link.id !== linkId; + }) + ); + }, + [orderedLinks] + ); + + const saveButtonComponent = useMemo(() => { + const canSave = orderedLinks.length !== 0; + + const button = ( + { + const newLinks = orderedLinks.reduce((prev, link, i) => { + return { ...prev, [link.id]: { ...link, order: i } }; + }, {} as NavigationEmbeddableLinkList); + onSave({ links: newLinks }); + }} + > + {NavEmbeddableStrings.editor.panelEditor.getSaveButtonLabel()} + ); - return newLinks; - }, [links]); + + return canSave ? ( + button + ) : ( + + {button} + + ); + }, [onSave, orderedLinks]); return ( <> +
-

{NavEmbeddableStrings.editor.panelEditor.getCreateFlyoutTitle()}

+

+ {initialInput.links && Object.keys(initialInput.links).length > 0 + ? NavEmbeddableStrings.editor.panelEditor.getEditFlyoutTitle() + : NavEmbeddableStrings.editor.panelEditor.getCreateFlyoutTitle()} +

<> - {!links || Object.keys(links).length === 0 ? ( + {orderedLinks.length === 0 ? ( - setShowLinkEditorFlyout(true)} - iconType="plusInCircle" - > + addOrEditLink()} iconType="plusInCircle"> {NavEmbeddableStrings.editor.getAddButtonLabel()} @@ -134,33 +191,31 @@ export const NavigationEmbeddablePanelEditor = ({ ) : ( <> - {linkList?.map((link) => { - return ( -
- + + {orderedLinks.map((link, idx) => ( + - - - - - -
{link.label}
-
-
-
- -
- ); - })} - setShowLinkEditorFlyout(true)} - > + {(provided) => ( + addOrEditLink(link)} + deleteLink={() => deleteLink(link.id)} + dragHandleProps={provided.dragHandleProps} + /> + )} + + ))} + + + addOrEditLink()}> {NavEmbeddableStrings.editor.getAddButtonLabel()} @@ -176,31 +231,13 @@ export const NavigationEmbeddablePanelEditor = ({ {NavEmbeddableStrings.editor.getCancelButtonLabel()} - - { - onSave({ ...initialInput, links }); - onClose(); - }} - > - {NavEmbeddableStrings.editor.panelEditor.getSaveButtonLabel()} - - + {saveButtonComponent} - - {showLinkEditorFlyout && ( - { - setShowLinkEditorFlyout(false); - }} - onSave={(newLink: NavigationEmbeddableLink) => { - setLinks({ ...links, [uuidv4()]: newLink }); - }} - parentDashboard={parentDashboard} - /> - )} ); }; + +// required for dynamic import using React.lazy() +// eslint-disable-next-line import/no-default-export +export default NavigationEmbeddablePanelEditor; diff --git a/src/plugins/navigation_embeddable/public/components/navigation_embeddable_panel_editor_link.tsx b/src/plugins/navigation_embeddable/public/components/navigation_embeddable_panel_editor_link.tsx new file mode 100644 index 0000000000000..be65c130222eb --- /dev/null +++ b/src/plugins/navigation_embeddable/public/components/navigation_embeddable_panel_editor_link.tsx @@ -0,0 +1,114 @@ +/* + * 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 React from 'react'; +import useAsync from 'react-use/lib/useAsync'; + +import { + EuiIcon, + EuiPanel, + EuiFlexItem, + EuiFlexGroup, + EuiButtonIcon, + EuiSkeletonTitle, + DraggableProvidedDragHandleProps, + EuiToolTip, +} from '@elastic/eui'; +import { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_container'; + +import { + NavigationLinkInfo, + DASHBOARD_LINK_TYPE, + NavigationEmbeddableLink, +} from '../embeddable/types'; +import { fetchDashboard } from './dashboard_link/dashboard_link_tools'; +import { NavEmbeddableStrings } from './navigation_embeddable_strings'; + +export const NavigationEmbeddablePanelEditorLink = ({ + link, + editLink, + deleteLink, + parentDashboard, + dragHandleProps, +}: { + editLink: () => void; + deleteLink: () => void; + link: NavigationEmbeddableLink; + parentDashboard?: DashboardContainer; + dragHandleProps?: DraggableProvidedDragHandleProps; +}) => { + const parentDashboardTitle = parentDashboard?.select((state) => state.explicitInput.title); + const parentDashboardId = parentDashboard?.select((state) => state.componentState.lastSavedId); + + const { value: linkLabel, loading: linkLabelLoading } = useAsync(async () => { + let label = link.label; + if (link.type === DASHBOARD_LINK_TYPE && !label) { + if (parentDashboardId === link.destination) { + label = parentDashboardTitle; + } else { + const dashboard = await fetchDashboard(link.destination); + label = dashboard.attributes.title; + } + } + return label || link.destination; + }, [link]); + + return ( + + + + + + + + + + + + +
{linkLabel}
+
+
+ + + + + + + + + + + + + + +
+
+ ); +}; diff --git a/src/plugins/navigation_embeddable/public/components/navigation_embeddable_strings.ts b/src/plugins/navigation_embeddable/public/components/navigation_embeddable_strings.ts index 7499eda011509..5628c3444d2d4 100644 --- a/src/plugins/navigation_embeddable/public/components/navigation_embeddable_strings.ts +++ b/src/plugins/navigation_embeddable/public/components/navigation_embeddable_strings.ts @@ -14,6 +14,18 @@ export const NavEmbeddableStrings = { i18n.translate('navigationEmbeddable.editor.addButtonLabel', { defaultMessage: 'Add link', }), + getUpdateButtonLabel: () => + i18n.translate('navigationEmbeddable.editor.updateButtonLabel', { + defaultMessage: 'Update link', + }), + getEditLinkTitle: () => + i18n.translate('navigationEmbeddable.editor.editLinkTitle', { + defaultMessage: 'Edit link', + }), + getDeleteLinkTitle: () => + i18n.translate('navigationEmbeddable.editor.deleteLinkTitle', { + defaultMessage: 'Delete link', + }), getCancelButtonLabel: () => i18n.translate('navigationEmbeddable.editor.cancelButtonLabel', { defaultMessage: 'Close', @@ -23,14 +35,30 @@ export const NavEmbeddableStrings = { i18n.translate('navigationEmbeddable.panelEditor.emptyLinksMessage', { defaultMessage: 'Use links to navigate to commonly used dashboards and websites.', }), + getEmptyLinksTooltip: () => + i18n.translate('navigationEmbeddable.panelEditor.emptyLinksTooltip', { + defaultMessage: 'Add one or more links.', + }), getCreateFlyoutTitle: () => i18n.translate('navigationEmbeddable.panelEditor.createFlyoutTitle', { defaultMessage: 'Create links panel', }), + getEditFlyoutTitle: () => + i18n.translate('navigationEmbeddable.panelEditor.editFlyoutTitle', { + defaultMessage: 'Edit links panel', + }), getSaveButtonLabel: () => i18n.translate('navigationEmbeddable.panelEditor.saveButtonLabel', { defaultMessage: 'Save', }), + getLinkLoadingAriaLabel: () => + i18n.translate('navigationEmbeddable.linkEditor.linkLoadingAriaLabel', { + defaultMessage: 'Loading link', + }), + getDragHandleAriaLabel: () => + i18n.translate('navigationEmbeddable.editor.dragHandleAriaLabel', { + defaultMessage: 'Link drag handle', + }), }, linkEditor: { getGoBackAriaLabel: () => @@ -47,7 +75,7 @@ export const NavEmbeddableStrings = { }), getLinkTextLabel: () => i18n.translate('navigationEmbeddable.linkEditor.linkTextLabel', { - defaultMessage: 'Text (optional)', + defaultMessage: 'Text', }), getLinkTextPlaceholder: () => i18n.translate('navigationEmbeddable.linkEditor.linkTextPlaceholder', { diff --git a/src/plugins/navigation_embeddable/public/editor/navigation_embeddable_editor_tools.tsx b/src/plugins/navigation_embeddable/public/editor/navigation_embeddable_editor_tools.tsx new file mode 100644 index 0000000000000..83cc4cfdc7c40 --- /dev/null +++ b/src/plugins/navigation_embeddable/public/editor/navigation_embeddable_editor_tools.tsx @@ -0,0 +1,34 @@ +/* + * 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 { memoize } from 'lodash'; +import { NavigationEmbeddableLink, NavigationEmbeddableLinkList } from '../embeddable/types'; + +const getOrderedLinkList = (links: NavigationEmbeddableLinkList): NavigationEmbeddableLink[] => { + return Object.keys(links) + .map((linkId) => { + return links[linkId]; + }) + .sort((linkA, linkB) => { + return linkA.order - linkB.order; + }); +}; + +/** + * Memoizing this prevents the navigation embeddable panel editor from having to unnecessarily calculate this + * a second time once the embeddable exists - after all, the navigation embeddable component should have already + * calculated this so, we can get away with using the cached version in the editor + */ +export const memoizedGetOrderedLinkList = memoize( + (links: NavigationEmbeddableLinkList) => { + return getOrderedLinkList(links); + }, + (links) => { + return links; + } +); diff --git a/src/plugins/navigation_embeddable/public/editor/open_editor_flyout.tsx b/src/plugins/navigation_embeddable/public/editor/open_editor_flyout.tsx index e47ed639f501d..19edb5fb4f2c8 100644 --- a/src/plugins/navigation_embeddable/public/editor/open_editor_flyout.tsx +++ b/src/plugins/navigation_embeddable/public/editor/open_editor_flyout.tsx @@ -8,14 +8,28 @@ import React from 'react'; import { Subject } from 'rxjs'; +import { memoize } from 'lodash'; import { skip, take, takeUntil } from 'rxjs/operators'; +import { withSuspense } from '@kbn/shared-ux-utility'; +import { EuiLoadingSpinner, EuiPanel } from '@elastic/eui'; import { toMountPoint } from '@kbn/kibana-react-plugin/public'; import { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_container'; import { coreServices } from '../services/kibana_services'; import { NavigationEmbeddableInput } from '../embeddable/types'; -import { NavigationEmbeddablePanelEditor } from '../components/navigation_embeddable_panel_editor'; +import { memoizedFetchDashboards } from '../components/dashboard_link/dashboard_link_tools'; + +const LazyNavigationEmbeddablePanelEditor = React.lazy( + () => import('../components/navigation_embeddable_panel_editor') +); + +const NavigationEmbeddablePanelEditor = withSuspense( + LazyNavigationEmbeddablePanelEditor, + + + +); /** * @throws in case user cancels @@ -60,10 +74,13 @@ export async function openEditorFlyout( ownFocus: true, outsideClickCloses: false, onClose: onCancel, + className: 'navEmbeddablePanelEditor', } ); editorFlyout.onClose.then(() => { + // we should always re-fetch the dashboards when the editor is opened; so, clear the cache on close + memoizedFetchDashboards.cache = new memoize.Cache(); closed$.next(true); }); }); diff --git a/src/plugins/navigation_embeddable/public/editor/open_link_editor_flyout.tsx b/src/plugins/navigation_embeddable/public/editor/open_link_editor_flyout.tsx new file mode 100644 index 0000000000000..794b8812d793b --- /dev/null +++ b/src/plugins/navigation_embeddable/public/editor/open_link_editor_flyout.tsx @@ -0,0 +1,77 @@ +/* + * 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 React from 'react'; +import ReactDOM from 'react-dom'; + +import { KibanaThemeProvider } from '@kbn/kibana-react-plugin/public'; +import { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_container'; + +import { coreServices } from '../services/kibana_services'; +import { NavigationEmbeddableLink } from '../embeddable/types'; +import { NavigationEmbeddableLinkEditor } from '../components/navigation_embeddable_link_editor'; + +export interface LinkEditorProps { + link?: NavigationEmbeddableLink; + parentDashboard?: DashboardContainer; + ref: React.RefObject; +} + +/** + * This editor has no context about other links, so it cannot determine order; order will be determined + * by the **caller** (i.e. the panel editor, which contains the context about **all links**) + */ +export type NavigationEmbeddableUnorderedLink = Omit; + +/** + * @throws in case user cancels + */ +export async function openLinkEditorFlyout({ + ref, + link, + parentDashboard, +}: LinkEditorProps): Promise { + const unmountFlyout = async () => { + if (ref.current) { + ref.current.children[1].className = 'navEmbeddableLinkEditor out'; + } + await new Promise(() => { + // wait for close animation before unmounting + setTimeout(() => { + if (ref.current) ReactDOM.unmountComponentAtNode(ref.current); + }, 180); + }); + }; + + return new Promise((resolve, reject) => { + const onSave = async (newLink: NavigationEmbeddableUnorderedLink) => { + resolve(newLink); + await unmountFlyout(); + }; + + const onCancel = async () => { + reject(); + await unmountFlyout(); + }; + + ReactDOM.render( + + + , + ref.current + ); + }).catch(() => { + // on reject (i.e. on cancel), just return the original list of links + return undefined; + }); +} diff --git a/src/plugins/navigation_embeddable/public/embeddable/navigation_embeddable_factory.ts b/src/plugins/navigation_embeddable/public/embeddable/navigation_embeddable_factory.ts index 8f9985b687665..8a9662492909a 100644 --- a/src/plugins/navigation_embeddable/public/embeddable/navigation_embeddable_factory.ts +++ b/src/plugins/navigation_embeddable/public/embeddable/navigation_embeddable_factory.ts @@ -33,7 +33,6 @@ export class NavigationEmbeddableFactoryDefinition implements EmbeddableFactoryDefinition { public readonly type = NAVIGATION_EMBEDDABLE_TYPE; - public isContainerType = false; public async isEditable() { @@ -75,11 +74,9 @@ export class NavigationEmbeddableFactoryDefinition ) { if (!parent) return {}; - const { openEditorFlyout: createNavigationEmbeddable } = await import( - '../editor/open_editor_flyout' - ); + const { openEditorFlyout } = await import('../editor/open_editor_flyout'); - const input = await createNavigationEmbeddable( + const input = await openEditorFlyout( { ...getDefaultNavigationEmbeddableInput(), ...initialInput }, parent ).catch(() => { diff --git a/src/plugins/navigation_embeddable/public/embeddable/types.ts b/src/plugins/navigation_embeddable/public/embeddable/types.ts index 40dd5901db948..0513d50fc8cb8 100644 --- a/src/plugins/navigation_embeddable/public/embeddable/types.ts +++ b/src/plugins/navigation_embeddable/public/embeddable/types.ts @@ -33,14 +33,19 @@ export const EXTERNAL_LINK_TYPE = 'externalLink'; export type NavigationLinkType = typeof DASHBOARD_LINK_TYPE | typeof EXTERNAL_LINK_TYPE; export interface NavigationEmbeddableLink { + id: string; type: NavigationLinkType; destination: string; - // order: number; TODO: Use this as part of https://github.com/elastic/kibana/issues/154361 label?: string; + order: number; +} + +export interface NavigationEmbeddableLinkList { + [id: string]: NavigationEmbeddableLink; } export interface NavigationEmbeddableInput extends EmbeddableInput { - links: { [id: string]: NavigationEmbeddableLink }; + links: NavigationEmbeddableLinkList; } export const NavigationLinkInfo: { diff --git a/src/plugins/navigation_embeddable/tsconfig.json b/src/plugins/navigation_embeddable/tsconfig.json index a7ea3f209f7ad..3c1cee2edb3d7 100644 --- a/src/plugins/navigation_embeddable/tsconfig.json +++ b/src/plugins/navigation_embeddable/tsconfig.json @@ -11,6 +11,7 @@ "@kbn/embeddable-plugin", "@kbn/kibana-react-plugin", "@kbn/presentation-util-plugin", + "@kbn/shared-ux-utility", ], "exclude": ["target/**/*"] }