diff --git a/src/plugins/dashboard/public/dashboard_app/top_nav/dashboard_editing_toolbar.tsx b/src/plugins/dashboard/public/dashboard_app/top_nav/dashboard_editing_toolbar.tsx index 708af176d785d..05962d387e476 100644 --- a/src/plugins/dashboard/public/dashboard_app/top_nav/dashboard_editing_toolbar.tsx +++ b/src/plugins/dashboard/public/dashboard_app/top_nav/dashboard_editing_toolbar.tsx @@ -101,7 +101,7 @@ export function DashboardEditingToolbar() { let explicitInput: Awaited>; try { - explicitInput = await embeddableFactory.getExplicitInput(); + explicitInput = await embeddableFactory.getExplicitInput(undefined, dashboard); } catch (e) { // error likely means user canceled embeddable creation return; diff --git a/src/plugins/dashboard/public/services/dashboard_content_management/dashboard_content_management_service.ts b/src/plugins/dashboard/public/services/dashboard_content_management/dashboard_content_management_service.ts index 5a5c67c798606..f16bd4442f2c1 100644 --- a/src/plugins/dashboard/public/services/dashboard_content_management/dashboard_content_management_service.ts +++ b/src/plugins/dashboard/public/services/dashboard_content_management/dashboard_content_management_service.ts @@ -65,11 +65,12 @@ export const dashboardContentManagementServiceFactory: DashboardContentManagemen dashboardSessionStorage, }), findDashboards: { - search: ({ hasReference, hasNoReference, search, size }) => + search: ({ hasReference, hasNoReference, search, size, options }) => searchDashboards({ contentManagement, hasNoReference, hasReference, + options, search, size, }), diff --git a/src/plugins/dashboard/public/services/dashboard_content_management/lib/find_dashboards.ts b/src/plugins/dashboard/public/services/dashboard_content_management/lib/find_dashboards.ts index 41bdd60d6c1d7..49ffee54d536b 100644 --- a/src/plugins/dashboard/public/services/dashboard_content_management/lib/find_dashboards.ts +++ b/src/plugins/dashboard/public/services/dashboard_content_management/lib/find_dashboards.ts @@ -18,6 +18,7 @@ import { DASHBOARD_CONTENT_ID } from '../../../dashboard_constants'; export interface SearchDashboardsArgs { contentManagement: DashboardStartDependencies['contentManagement']; + options?: DashboardCrudTypes['SearchIn']['options']; hasNoReference?: SavedObjectsFindOptionsReference[]; hasReference?: SavedObjectsFindOptionsReference[]; search: string; @@ -33,6 +34,7 @@ export async function searchDashboards({ contentManagement, hasNoReference, hasReference, + options, search, size, }: SearchDashboardsArgs): Promise { @@ -52,6 +54,7 @@ export async function searchDashboards({ excluded: (hasNoReference ?? []).map(({ id }) => id), }, }, + options, }); return { total, diff --git a/src/plugins/dashboard/public/services/dashboard_content_management/types.ts b/src/plugins/dashboard/public/services/dashboard_content_management/types.ts index 92fbe3005e9e4..858d5800961b5 100644 --- a/src/plugins/dashboard/public/services/dashboard_content_management/types.ts +++ b/src/plugins/dashboard/public/services/dashboard_content_management/types.ts @@ -87,7 +87,10 @@ export interface SaveDashboardReturn { */ export interface FindDashboardsService { search: ( - props: Pick + props: Pick< + SearchDashboardsArgs, + 'hasReference' | 'hasNoReference' | 'search' | 'size' | 'options' + > ) => Promise; findByIds: (ids: string[]) => Promise; findByTitle: (title: string) => Promise<{ id: string } | undefined>; diff --git a/src/plugins/embeddable/public/lib/actions/edit_panel_action.ts b/src/plugins/embeddable/public/lib/actions/edit_panel_action.ts index ba59d92cbef60..98e541fd08e6f 100644 --- a/src/plugins/embeddable/public/lib/actions/edit_panel_action.ts +++ b/src/plugins/embeddable/public/lib/actions/edit_panel_action.ts @@ -92,7 +92,7 @@ export class EditPanelAction implements Action { } const oldExplicitInput = embeddable.getExplicitInput(); - const newExplicitInput = await factory.getExplicitInput(oldExplicitInput); + const newExplicitInput = await factory.getExplicitInput(oldExplicitInput, embeddable.parent); embeddable.parent?.replaceEmbeddable(embeddable.id, newExplicitInput); return; } diff --git a/src/plugins/embeddable/public/lib/containers/container.ts b/src/plugins/embeddable/public/lib/containers/container.ts index a53cfb8725fac..2f29410bc3e59 100644 --- a/src/plugins/embeddable/public/lib/containers/container.ts +++ b/src/plugins/embeddable/public/lib/containers/container.ts @@ -301,7 +301,7 @@ export abstract class Container< public async getExplicitInputIsEqual(lastInput: TContainerInput) { const { panels: lastPanels, ...restOfLastInput } = lastInput; - const { panels: currentPanels, ...restOfCurrentInput } = this.getInput(); + const { panels: currentPanels, ...restOfCurrentInput } = this.getExplicitInput(); const otherInputIsEqual = isEqual(restOfLastInput, restOfCurrentInput); if (!otherInputIsEqual) return false; @@ -371,7 +371,6 @@ export abstract class Container< initializeSettings?: EmbeddableContainerSettings ) { let initializeOrder = Object.keys(initialInput.panels); - if (initializeSettings?.childIdInitializeOrder) { const initializeOrderSet = new Set(); diff --git a/src/plugins/embeddable/public/lib/embeddables/embeddable_factory.ts b/src/plugins/embeddable/public/lib/embeddables/embeddable_factory.ts index 0d4aa5f150abc..f785ea9195930 100644 --- a/src/plugins/embeddable/public/lib/embeddables/embeddable_factory.ts +++ b/src/plugins/embeddable/public/lib/embeddables/embeddable_factory.ts @@ -99,7 +99,10 @@ export interface EmbeddableFactory< * * Can be used to edit an embeddable by re-requesting explicit input. Initial input can be provided to allow the editor to show the current state. */ - getExplicitInput(initialInput?: Partial): Promise>; + getExplicitInput( + initialInput?: Partial, + parent?: IContainer + ): Promise>; /** * Creates a new embeddable instance based off the saved object id. diff --git a/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx b/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx index c6bef2db73ac9..d964dd8c4b0b4 100644 --- a/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx +++ b/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx @@ -430,27 +430,28 @@ export class EmbeddablePanel extends React.Component { }; private getActionContextMenuPanel = async () => { - let regularActions = + const regularActions = (await this.props.getActions?.(CONTEXT_MENU_TRIGGER, { embeddable: this.props.embeddable, })) ?? []; const { disabledActions } = this.props.embeddable.getInput(); + + let allActions = regularActions.concat( + Object.values(this.state.universalActions ?? {}) as Array> + ); if (disabledActions) { const removeDisabledActions = removeById(disabledActions); - regularActions = regularActions.filter(removeDisabledActions); + allActions = allActions.filter(removeDisabledActions); } - let sortedActions = regularActions - .concat(Object.values(this.state.universalActions || {}) as Array>) - .sort(sortByOrderField); - if (this.props.actionPredicate) { - sortedActions = sortedActions.filter(({ id }) => this.props.actionPredicate!(id)); + allActions = allActions.filter(({ id }) => this.props.actionPredicate!(id)); } + allActions.sort(sortByOrderField); const panels = await buildContextMenuForActions({ - actions: sortedActions.map((action) => ({ + actions: allActions.map((action) => ({ action, context: { embeddable: this.props.embeddable }, trigger: contextMenuTrigger, @@ -460,7 +461,7 @@ export class EmbeddablePanel extends React.Component { return { panels, - actions: sortedActions, + actions: allActions, }; }; } diff --git a/src/plugins/navigation_embeddable/kibana.jsonc b/src/plugins/navigation_embeddable/kibana.jsonc index 76d185c560084..961aacc7641aa 100644 --- a/src/plugins/navigation_embeddable/kibana.jsonc +++ b/src/plugins/navigation_embeddable/kibana.jsonc @@ -1,17 +1,14 @@ { "type": "plugin", - "id": "@kbn/navigation-embeddable-plugin", "owner": "@elastic/kibana-presentation", + "id": "@kbn/navigation-embeddable-plugin", "description": "An embeddable for quickly navigating between dashboards.", "plugin": { "id": "navigationEmbeddable", "server": false, "browser": true, - "requiredPlugins": [ - "embeddable" - ], - "optionalPlugins": [], - "requiredBundles": [ - ] + "requiredPlugins": ["dashboard", "embeddable", "kibanaReact", "presentationUtil"], + "optionalPlugins": ["triggersActionsUi"], + "requiredBundles": [] } } diff --git a/src/plugins/navigation_embeddable/public/_mixins.scss b/src/plugins/navigation_embeddable/public/_mixins.scss new file mode 100644 index 0000000000000..47249b0aa42d4 --- /dev/null +++ b/src/plugins/navigation_embeddable/public/_mixins.scss @@ -0,0 +1,25 @@ +@import '../../../core/public/mixins'; + +@keyframes euiFlyoutAnimation { + 0% { + opacity: 0; + transform: translateX(100%); + } + + 100% { + opacity: 1; + transform: translateX(0%); + } +} + +@mixin euiFlyout { + @include kibanaFullBodyHeight(); + border-left: $euiBorderThin; + position: fixed; + width: 50%; + z-index: $euiZFlyout; + background: $euiColorEmptyShade; + display: flex; + flex-direction: column; + align-items: stretch; +} \ 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 new file mode 100644 index 0000000000000..7c8170674d9ea --- /dev/null +++ b/src/plugins/navigation_embeddable/public/components/dashboard_link/dashboard_link_component.tsx @@ -0,0 +1,54 @@ +/* + * 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 { EuiButtonEmpty } from '@elastic/eui'; +import { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_container'; + +import { + DASHBOARD_LINK_TYPE, + NavigationEmbeddableLink, + NavigationLinkInfo, +} from '../../embeddable/types'; +import { fetchDashboard } from './dashboard_link_tools'; +import { useNavigationEmbeddable } from '../../embeddable/navigation_embeddable'; + +export const DashboardLinkComponent = ({ link }: { link: NavigationEmbeddableLink }) => { + const navEmbeddable = useNavigationEmbeddable(); + + const dashboardContainer = navEmbeddable.parent as DashboardContainer; + const parentDashboardTitle = dashboardContainer.select((state) => state.explicitInput.title); + const parentDashboardId = dashboardContainer.select((state) => state.componentState.lastSavedId); + + const { loading: loadingDestinationDashboard, value: destinationDashboard } = + useAsync(async () => { + return await fetchDashboard(link.destination); + }, [link, parentDashboardId]); + + return ( + {}, // TODO: As part of https://github.com/elastic/kibana/issues/154381, connect to drilldown + })} + > + {link.label || + (link.destination === parentDashboardId + ? parentDashboardTitle + : destinationDashboard?.attributes.title)} + + ); +}; 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 new file mode 100644 index 0000000000000..715434dc2c80f --- /dev/null +++ b/src/plugins/navigation_embeddable/public/components/dashboard_link/dashboard_link_destination_picker.tsx @@ -0,0 +1,118 @@ +/* + * 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 { debounce } from 'lodash'; +import useAsync from 'react-use/lib/useAsync'; +import React, { useEffect, useMemo, useState } from 'react'; + +import { + EuiBadge, + EuiSpacer, + EuiHighlight, + EuiSelectable, + EuiFieldSearch, + EuiSelectableOption, +} from '@elastic/eui'; +import { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_container'; +import { DashboardItem } from '../../embeddable/types'; +import { memoizedFetchDashboards } from './dashboard_link_tools'; +import { DashboardLinkEmbeddableStrings } from './dashboard_link_strings'; + +export const DashboardLinkDestinationPicker = ({ + setDestination, + setPlaceholder, + currentDestination, + parentDashboard, + ...other +}: { + setDestination: (destination?: string) => void; + setPlaceholder: (placeholder?: string) => void; + currentDestination?: string; + parentDashboard?: DashboardContainer; +}) => { + const [searchString, setSearchString] = useState(''); + const [selectedDashboard, setSelectedDashboard] = useState(); + const [dashboardListOptions, setDashboardListOptions] = useState([]); + + const parentDashboardId = parentDashboard?.select((state) => state.componentState.lastSavedId); + + const { loading: loadingDashboardList, value: dashboardList } = useAsync(async () => { + return await memoizedFetchDashboards(searchString, undefined, parentDashboardId); + }, [searchString, parentDashboardId]); + + useEffect(() => { + const dashboardOptions = + (dashboardList ?? []).map((dashboard: DashboardItem) => { + return { + data: dashboard, + label: dashboard.attributes.title, + ...(dashboard.id === parentDashboardId + ? { + prepend: ( + {DashboardLinkEmbeddableStrings.getCurrentDashboardLabel()} + ), + } + : {}), + } as EuiSelectableOption; + }) ?? []; + + setDashboardListOptions(dashboardOptions); + }, [dashboardList, parentDashboardId, searchString]); + + const debouncedSetSearch = useMemo( + () => + debounce((newSearch: string) => { + setSearchString(newSearch); + }, 250), + [setSearchString] + ); + + useEffect(() => { + if (selectedDashboard) { + setDestination(selectedDashboard.id); + setPlaceholder(selectedDashboard.attributes.title); + } else { + setDestination(undefined); + setPlaceholder(undefined); + } + }, [selectedDashboard, setDestination, setPlaceholder]); + + /* {...other} is needed so all inner elements are 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} + +
+ ); +}; 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 new file mode 100644 index 0000000000000..9bc2e2d40f0b0 --- /dev/null +++ b/src/plugins/navigation_embeddable/public/components/dashboard_link/dashboard_link_strings.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { i18n } from '@kbn/i18n'; + +export const DashboardLinkEmbeddableStrings = { + getDisplayName: () => + i18n.translate('navigationEmbeddable.dashboardLink.displayName', { + defaultMessage: 'Dashboard', + }), + getDescription: () => + i18n.translate('navigationEmbeddable.dsahboardLink.description', { + defaultMessage: 'Go to dashboard', + }), + getSearchPlaceholder: () => + i18n.translate('navigationEmbeddable.dashboardLink.editor.searchPlaceholder', { + defaultMessage: 'Search for a dashboard', + }), + getDashboardPickerAriaLabel: () => + i18n.translate('navigationEmbeddable.dashboardLink.editor.dashboardPickerAriaLabel', { + defaultMessage: 'Pick a destination dashboard', + }), + getCurrentDashboardLabel: () => + i18n.translate('navigationEmbeddable.dashboardLink.editor.currentDashboardLabel', { + defaultMessage: 'Current', + }), +}; 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 new file mode 100644 index 0000000000000..3735e5a044ffa --- /dev/null +++ b/src/plugins/navigation_embeddable/public/components/dashboard_link/dashboard_link_tools.tsx @@ -0,0 +1,89 @@ +/* + * 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 { isEmpty, memoize } 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. + */ +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(); + const response = (await findDashboardsService.findByIds([dashboardId]))[0]; + if (response.status === 'error') { + throw new Error('failure'); // TODO: better error handling + } + return response; +}; + +export const memoizedFetchDashboards = memoize( + async (search: string = '', size: number = 10, currentDashboardId?: string) => { + return await fetchDashboards(search, size, currentDashboardId); + }, + (search, size, currentDashboardId) => { + return [search, size, currentDashboardId].join('|'); + } +); + +const fetchDashboards = async ( + search: string = '', + size: number = 10, + currentDashboardId?: string +): Promise => { + const findDashboardsService = await dashboardServices.findDashboardsService(); + const responses = await findDashboardsService.search({ + search, + size, + 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 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 + } + } + + /** Then, only return the parts of the dashboard object that we need */ + const simplifiedDashboardList = dashboardList.map((hit) => { + return { id: hit.id, attributes: hit.attributes }; + }); + + return simplifiedDashboardList; +}; diff --git a/src/plugins/navigation_embeddable/public/components/external_link/external_link_component.tsx b/src/plugins/navigation_embeddable/public/components/external_link/external_link_component.tsx new file mode 100644 index 0000000000000..90bf4066d4c20 --- /dev/null +++ b/src/plugins/navigation_embeddable/public/components/external_link/external_link_component.tsx @@ -0,0 +1,24 @@ +/* + * 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 { EuiButtonEmpty } from '@elastic/eui'; +import { + EXTERNAL_LINK_TYPE, + NavigationLinkInfo, + NavigationEmbeddableLink, +} from '../../embeddable/types'; + +export const ExternalLinkComponent = ({ link }: { link: NavigationEmbeddableLink }) => { + return ( + + {link.label || link.destination} + + ); +}; 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 new file mode 100644 index 0000000000000..596ce183d696b --- /dev/null +++ b/src/plugins/navigation_embeddable/public/components/external_link/external_link_destination_picker.tsx @@ -0,0 +1,45 @@ +/* + * 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, { useState } from 'react'; +import { EuiFieldText } from '@elastic/eui'; +import { ExternalLinkEmbeddableStrings } from './external_link_strings'; + +// TODO: As part of https://github.com/elastic/kibana/issues/154381, replace this regex URL check with more robust url validation +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, + ...other +}: { + setDestination: (destination?: string) => void; + setPlaceholder: (placeholder?: string) => void; + currentDestination?: string; +}) => { + const [validUrl, setValidUrl] = useState(true); + + /* {...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); + }} + /> +
+ ); +}; diff --git a/src/plugins/navigation_embeddable/public/components/external_link/external_link_strings.ts b/src/plugins/navigation_embeddable/public/components/external_link/external_link_strings.ts new file mode 100644 index 0000000000000..77d7b479706b6 --- /dev/null +++ b/src/plugins/navigation_embeddable/public/components/external_link/external_link_strings.ts @@ -0,0 +1,24 @@ +/* + * 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 { i18n } from '@kbn/i18n'; + +export const ExternalLinkEmbeddableStrings = { + getDisplayName: () => + i18n.translate('navigationEmbeddable.externalLink.displayName', { + defaultMessage: 'URL', + }), + getDescription: () => + i18n.translate('navigationEmbeddable.externalLink.description', { + defaultMessage: 'Go to URL', + }), + getPlaceholder: () => + i18n.translate('navigationEmbeddable.externalLink.editor.placeholder', { + defaultMessage: 'Enter external URL', + }), +}; diff --git a/src/plugins/navigation_embeddable/public/components/navigation_embeddable.scss b/src/plugins/navigation_embeddable/public/components/navigation_embeddable.scss new file mode 100644 index 0000000000000..214cbbc7a8760 --- /dev/null +++ b/src/plugins/navigation_embeddable/public/components/navigation_embeddable.scss @@ -0,0 +1,23 @@ +@import '../mixins'; + +.navEmbeddableLinkEditor { + @include euiFlyout; + animation: euiFlyoutAnimation $euiAnimSpeedNormal $euiAnimSlightResistance; + + .linkEditorBackButton { + height: auto; + } +} + +.navEmbeddablePanelEditor { + .linkText { + flex: 1; + min-width: 0; + + .wrapText { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + } +} \ 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 new file mode 100644 index 0000000000000..abcbe098f3896 --- /dev/null +++ b/src/plugins/navigation_embeddable/public/components/navigation_embeddable_component.tsx @@ -0,0 +1,44 @@ +/* + * 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 { 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'; + +export const NavigationEmbeddableComponent = () => { + const navEmbeddable = useNavigationEmbeddable(); + + const links = navEmbeddable.select((state) => state.explicitInput.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) => { + return ( + + {links[linkId].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 new file mode 100644 index 0000000000000..a0753130bcc83 --- /dev/null +++ b/src/plugins/navigation_embeddable/public/components/navigation_embeddable_link_editor.tsx @@ -0,0 +1,175 @@ +/* + * 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, { useMemo, useState } from 'react'; + +import { + EuiForm, + EuiIcon, + EuiTitle, + EuiButton, + EuiFormRow, + EuiFlexItem, + EuiFieldText, + EuiFocusTrap, + EuiFlexGroup, + EuiRadioGroup, + EuiFlyoutBody, + EuiButtonEmpty, + EuiFlyoutFooter, + EuiFlyoutHeader, + EuiRadioGroupOption, +} from '@elastic/eui'; +import { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_container'; + +import { + NavigationLinkInfo, + NavigationLinkType, + EXTERNAL_LINK_TYPE, + DASHBOARD_LINK_TYPE, + NavigationEmbeddableLink, +} from '../embeddable/types'; +import { NavEmbeddableStrings } from './navigation_embeddable_strings'; +import { ExternalLinkDestinationPicker } from './external_link/external_link_destination_picker'; +import { DashboardLinkDestinationPicker } from './dashboard_link/dashboard_link_destination_picker'; + +export const NavigationEmbeddableLinkEditor = ({ + onSave, + onClose, + parentDashboard, +}: { + onClose: () => void; + parentDashboard?: DashboardContainer; + onSave: (newLink: NavigationEmbeddableLink) => void; +}) => { + const [selectedLinkType, setSelectedLinkType] = useState(DASHBOARD_LINK_TYPE); + const [linkLabel, setLinkLabel] = useState(''); + const [linkDestination, setLinkDestination] = useState(); + const [linkLabelPlaceholder, setLinkLabelPlaceholder] = useState(); + + const linkTypes: EuiRadioGroupOption[] = useMemo(() => { + return ([DASHBOARD_LINK_TYPE, EXTERNAL_LINK_TYPE] as NavigationLinkType[]).map((type) => { + return { + id: type, + label: ( + + + + + {NavigationLinkInfo[type].displayName} + + ), + }; + }); + }, []); + + return ( + + + onClose()} + > + +

{NavEmbeddableStrings.editor.getAddButtonLabel()}

+
+
+
+ + + + { + setLinkDestination(undefined); + setLinkLabelPlaceholder(undefined); + setSelectedLinkType(id as NavigationLinkType); + }} + /> + + + + {selectedLinkType === DASHBOARD_LINK_TYPE ? ( + + ) : ( + + )} + + + + { + setLinkLabel(e.target.value); + }} + /> + + + + {/* TODO: As part of https://github.com/elastic/kibana/issues/154381, we should pull in the custom settings for each link type. + Refer to `x-pack/examples/ui_actions_enhanced_examples/public/drilldowns/dashboard_to_discover_drilldown/collect_config_container.tsx` + for the dashboard drilldown settings, for example. + + Open question: It probably makes sense to re-use these components so any changes made to the drilldown architecture + trickle down to the navigation embeddable - this would require some refactoring, though. Is this a goal for MVP? + */} + + + + + onClose()} iconType="cross"> + {NavEmbeddableStrings.editor.getCancelButtonLabel()} + + + + { + // 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, + type: selectedLinkType, + }); + onClose(); + } + }} + > + {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 new file mode 100644 index 0000000000000..ab24c998feab0 --- /dev/null +++ b/src/plugins/navigation_embeddable/public/components/navigation_embeddable_panel_editor.tsx @@ -0,0 +1,192 @@ +/* + * 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 { isEmpty } from 'lodash'; +import { v4 as uuidv4 } from 'uuid'; +import React, { useState } from 'react'; +import useAsync from 'react-use/lib/useAsync'; + +import { + EuiText, + EuiIcon, + EuiForm, + EuiTitle, + EuiPanel, + IconType, + EuiSpacer, + EuiButton, + EuiFormRow, + EuiFlexItem, + EuiFlexGroup, + EuiFlyoutBody, + EuiButtonEmpty, + EuiFlyoutFooter, + EuiFlyoutHeader, +} from '@elastic/eui'; +import { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_container'; + +import { + DASHBOARD_LINK_TYPE, + EXTERNAL_LINK_TYPE, + NavigationEmbeddableInput, + NavigationEmbeddableLink, + NavigationLinkInfo, +} 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 './navigation_embeddable.scss'; + +export const NavigationEmbeddablePanelEditor = ({ + onSave, + onClose, + initialInput, + parentDashboard, +}: { + onClose: () => void; + initialInput: Partial; + onSave: (input: Partial) => void; + parentDashboard?: DashboardContainer; +}) => { + const [showLinkEditorFlyout, setShowLinkEditorFlyout] = useState(false); + const [links, setLinks] = useState(initialInput.links); + + /** + * 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; + } + } + + return { id: panelId, label: label || links[panelId].destination, icon }; + }) + ); + return newLinks; + }, [links]); + + return ( + <> + + +

{NavEmbeddableStrings.editor.panelEditor.getCreateFlyoutTitle()}

+
+
+ + + + <> + {!links || Object.keys(links).length === 0 ? ( + + + + + {NavEmbeddableStrings.editor.panelEditor.getEmptyLinksMessage()} + + + + + + + setShowLinkEditorFlyout(true)} + iconType="plusInCircle" + > + {NavEmbeddableStrings.editor.getAddButtonLabel()} + + + + + ) : ( + <> + {linkList?.map((link) => { + return ( +
+ + + + + + +
{link.label}
+
+
+
+ +
+ ); + })} + setShowLinkEditorFlyout(true)} + > + {NavEmbeddableStrings.editor.getAddButtonLabel()} + + + )} + +
+
+
+ + + + + {NavEmbeddableStrings.editor.getCancelButtonLabel()} + + + + { + onSave({ ...initialInput, links }); + onClose(); + }} + > + {NavEmbeddableStrings.editor.panelEditor.getSaveButtonLabel()} + + + + + + {showLinkEditorFlyout && ( + { + setShowLinkEditorFlyout(false); + }} + onSave={(newLink: NavigationEmbeddableLink) => { + setLinks({ ...links, [uuidv4()]: newLink }); + }} + parentDashboard={parentDashboard} + /> + )} + + ); +}; diff --git a/src/plugins/navigation_embeddable/public/components/navigation_embeddable_strings.ts b/src/plugins/navigation_embeddable/public/components/navigation_embeddable_strings.ts new file mode 100644 index 0000000000000..28faa11a00d23 --- /dev/null +++ b/src/plugins/navigation_embeddable/public/components/navigation_embeddable_strings.ts @@ -0,0 +1,58 @@ +/* + * 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 { i18n } from '@kbn/i18n'; + +export const NavEmbeddableStrings = { + editor: { + getAddButtonLabel: () => + i18n.translate('navigationEmbeddable.editor.addButtonLabel', { + defaultMessage: 'Add link', + }), + getCancelButtonLabel: () => + i18n.translate('navigationEmbeddable.editor.cancelButtonLabel', { + defaultMessage: 'Close', + }), + panelEditor: { + getEmptyLinksMessage: () => + i18n.translate('navigationEmbeddable.panelEditor.emptyLinksMessage', { + defaultMessage: "You haven't added any links yet.", + }), + getCreateFlyoutTitle: () => + i18n.translate('navigationEmbeddable.panelEditor.createFlyoutTitle', { + defaultMessage: 'Create links panel', + }), + getSaveButtonLabel: () => + i18n.translate('navigationEmbeddable.panelEditor.saveButtonLabel', { + defaultMessage: 'Save', + }), + }, + linkEditor: { + getGoBackAriaLabel: () => + i18n.translate('navigationEmbeddable.linkEditor.goBackAriaLabel', { + defaultMessage: 'Go back to panel editor.', + }), + getLinkTypePickerLabel: () => + i18n.translate('navigationEmbeddable.linkEditor.linkTypeFormLabel', { + defaultMessage: 'Go to', + }), + getLinkDestinationLabel: () => + i18n.translate('navigationEmbeddable.linkEditor.linkDestinationLabel', { + defaultMessage: 'Choose destination', + }), + getLinkTextLabel: () => + i18n.translate('navigationEmbeddable.linkEditor.linkTextLabel', { + defaultMessage: 'Text (optional)', + }), + getLinkTextPlaceholder: () => + i18n.translate('navigationEmbeddable.linkEditor.linkTextPlaceholder', { + defaultMessage: 'Enter text for link', + }), + }, + }, +}; diff --git a/src/plugins/navigation_embeddable/public/editor/open_editor_flyout.tsx b/src/plugins/navigation_embeddable/public/editor/open_editor_flyout.tsx new file mode 100644 index 0000000000000..e47ed639f501d --- /dev/null +++ b/src/plugins/navigation_embeddable/public/editor/open_editor_flyout.tsx @@ -0,0 +1,70 @@ +/* + * 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 { Subject } from 'rxjs'; +import { skip, take, takeUntil } from 'rxjs/operators'; + +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'; + +/** + * @throws in case user cancels + */ +export async function openEditorFlyout( + initialInput?: Omit, + parentDashboard?: DashboardContainer +): Promise> { + return new Promise((resolve, reject) => { + const closed$ = new Subject(); + + const onSave = (partialInput: Partial) => { + resolve(partialInput); + editorFlyout.close(); + }; + + const onCancel = () => { + reject(); + editorFlyout.close(); + }; + + // Close the flyout whenever the breadcrumbs change - i.e. when the dashboard's title changes, or when + // the user navigates away from the given dashboard (to the listing page **or** to another app), etc. + coreServices.chrome + .getBreadcrumbs$() + .pipe(takeUntil(closed$), skip(1), take(1)) + .subscribe(() => { + editorFlyout.close(); + }); + + const editorFlyout = coreServices.overlays.openFlyout( + toMountPoint( + , + { theme$: coreServices.theme.theme$ } + ), + { + ownFocus: true, + outsideClickCloses: false, + onClose: onCancel, + } + ); + + editorFlyout.onClose.then(() => { + closed$.next(true); + }); + }); +} diff --git a/src/plugins/navigation_embeddable/public/navigation_embeddable/index.ts b/src/plugins/navigation_embeddable/public/embeddable/index.ts similarity index 81% rename from src/plugins/navigation_embeddable/public/navigation_embeddable/index.ts rename to src/plugins/navigation_embeddable/public/embeddable/index.ts index 1676f521ceb84..12c60f3ebd004 100644 --- a/src/plugins/navigation_embeddable/public/navigation_embeddable/index.ts +++ b/src/plugins/navigation_embeddable/public/embeddable/index.ts @@ -6,6 +6,9 @@ * Side Public License, v 1. */ -export { NAVIGATION_EMBEDDABLE_TYPE, NavigationEmbeddable } from './navigation_embeddable'; +export { + NAVIGATION_EMBEDDABLE_TYPE, + NavigationEmbeddable as NavigationEmbeddable, +} from './navigation_embeddable'; export type { NavigationEmbeddableFactory } from './navigation_embeddable_factory'; export { NavigationEmbeddableFactoryDefinition } from './navigation_embeddable_factory'; diff --git a/src/plugins/navigation_embeddable/public/embeddable/navigation_embeddable.tsx b/src/plugins/navigation_embeddable/public/embeddable/navigation_embeddable.tsx new file mode 100644 index 0000000000000..9f3194d0b376c --- /dev/null +++ b/src/plugins/navigation_embeddable/public/embeddable/navigation_embeddable.tsx @@ -0,0 +1,97 @@ +/* + * 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, { createContext, useContext } from 'react'; + +import { Embeddable, EmbeddableOutput } from '@kbn/embeddable-plugin/public'; +import { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_container'; +import { ReduxEmbeddableTools, ReduxToolsPackage } from '@kbn/presentation-util-plugin/public'; + +import { navigationEmbeddableReducers } from './navigation_embeddable_reducers'; +import { NavigationEmbeddableInput, NavigationEmbeddableReduxState } from './types'; +import { NavigationEmbeddableComponent } from '../components/navigation_embeddable_component'; + +export const NAVIGATION_EMBEDDABLE_TYPE = 'navigation'; + +export const NavigationEmbeddableContext = createContext(null); +export const useNavigationEmbeddable = (): NavigationEmbeddable => { + const navigation = useContext(NavigationEmbeddableContext); + if (navigation == null) { + throw new Error('useNavigation must be used inside NavigationEmbeddableContext.'); + } + return navigation!; +}; + +type NavigationReduxEmbeddableTools = ReduxEmbeddableTools< + NavigationEmbeddableReduxState, + typeof navigationEmbeddableReducers +>; + +export interface NavigationEmbeddableConfig { + editable: boolean; +} + +export class NavigationEmbeddable extends Embeddable { + public readonly type = NAVIGATION_EMBEDDABLE_TYPE; + + // state management + public select: NavigationReduxEmbeddableTools['select']; + public getState: NavigationReduxEmbeddableTools['getState']; + public dispatch: NavigationReduxEmbeddableTools['dispatch']; + public onStateChange: NavigationReduxEmbeddableTools['onStateChange']; + + private cleanupStateTools: () => void; + + constructor( + reduxToolsPackage: ReduxToolsPackage, + config: NavigationEmbeddableConfig, + initialInput: NavigationEmbeddableInput, + parent?: DashboardContainer + ) { + super( + initialInput, + { + editable: config.editable, + editableWithExplicitInput: true, + }, + parent + ); + + /** Build redux embeddable tools */ + const reduxEmbeddableTools = reduxToolsPackage.createReduxEmbeddableTools< + NavigationEmbeddableReduxState, + typeof navigationEmbeddableReducers + >({ + embeddable: this, + reducers: navigationEmbeddableReducers, + initialComponentState: {}, + }); + + this.select = reduxEmbeddableTools.select; + this.getState = reduxEmbeddableTools.getState; + this.dispatch = reduxEmbeddableTools.dispatch; + this.cleanupStateTools = reduxEmbeddableTools.cleanup; + this.onStateChange = reduxEmbeddableTools.onStateChange; + this.setInitializationFinished(); + } + + public async reload() {} + + public destroy() { + super.destroy(); + this.cleanupStateTools(); + } + + public render() { + return ( + + + + ); + } +} diff --git a/src/plugins/navigation_embeddable/public/embeddable/navigation_embeddable_factory.ts b/src/plugins/navigation_embeddable/public/embeddable/navigation_embeddable_factory.ts new file mode 100644 index 0000000000000..8f9985b687665 --- /dev/null +++ b/src/plugins/navigation_embeddable/public/embeddable/navigation_embeddable_factory.ts @@ -0,0 +1,102 @@ +/* + * 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 { isEmpty } from 'lodash'; + +import { i18n } from '@kbn/i18n'; +import { + ACTION_ADD_PANEL, + EmbeddableFactory, + EmbeddableFactoryDefinition, +} from '@kbn/embeddable-plugin/public'; +import { lazyLoadReduxToolsPackage } from '@kbn/presentation-util-plugin/public'; +import { DashboardContainer } from '@kbn/dashboard-plugin/public/dashboard_container'; + +import { NavigationEmbeddableInput } from './types'; +import { NAVIGATION_EMBEDDABLE_TYPE } from './navigation_embeddable'; +import { coreServices, untilPluginStartServicesReady } from '../services/kibana_services'; + +export type NavigationEmbeddableFactory = EmbeddableFactory; + +// TODO: Replace string 'OPEN_FLYOUT_ADD_DRILLDOWN' with constant as part of https://github.com/elastic/kibana/issues/154381 +const getDefaultNavigationEmbeddableInput = (): Omit => ({ + links: {}, + disabledActions: [ACTION_ADD_PANEL, 'OPEN_FLYOUT_ADD_DRILLDOWN'], +}); + +export class NavigationEmbeddableFactoryDefinition + implements EmbeddableFactoryDefinition +{ + public readonly type = NAVIGATION_EMBEDDABLE_TYPE; + + public isContainerType = false; + + public async isEditable() { + await untilPluginStartServicesReady(); + return Boolean(coreServices.application.capabilities.dashboard?.showWriteControls); + } + + public canCreateNew() { + return true; + } + + public getDefaultInput(): Partial { + return getDefaultNavigationEmbeddableInput(); + } + + public async create(initialInput: NavigationEmbeddableInput, parent: DashboardContainer) { + if (!initialInput.links || isEmpty(initialInput.links)) { + // don't create an empty navigation embeddable - it should always have at least one link + return; + } + + await untilPluginStartServicesReady(); + + const reduxEmbeddablePackage = await lazyLoadReduxToolsPackage(); + const { NavigationEmbeddable } = await import('./navigation_embeddable'); + const editable = await this.isEditable(); + + return new NavigationEmbeddable( + reduxEmbeddablePackage, + { editable }, + { ...getDefaultNavigationEmbeddableInput(), ...initialInput }, + parent + ); + } + + public async getExplicitInput( + initialInput?: NavigationEmbeddableInput, + parent?: DashboardContainer + ) { + if (!parent) return {}; + + const { openEditorFlyout: createNavigationEmbeddable } = await import( + '../editor/open_editor_flyout' + ); + + const input = await createNavigationEmbeddable( + { ...getDefaultNavigationEmbeddableInput(), ...initialInput }, + parent + ).catch(() => { + // swallow the promise rejection that happens when the flyout is closed + return {}; + }); + + return input; + } + + public getDisplayName() { + return i18n.translate('navigationEmbeddable.navigationEmbeddableFactory.displayName', { + defaultMessage: 'Links', + }); + } + + public getIconType() { + return 'link'; + } +} diff --git a/src/plugins/navigation_embeddable/public/embeddable/navigation_embeddable_reducers.ts b/src/plugins/navigation_embeddable/public/embeddable/navigation_embeddable_reducers.ts new file mode 100644 index 0000000000000..29a79c4f6154f --- /dev/null +++ b/src/plugins/navigation_embeddable/public/embeddable/navigation_embeddable_reducers.ts @@ -0,0 +1,27 @@ +/* + * 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 { WritableDraft } from 'immer/dist/types/types-external'; + +import { PayloadAction } from '@reduxjs/toolkit'; + +import { NavigationEmbeddableReduxState } from './types'; + +export const navigationEmbeddableReducers = { + /** + * TODO: Right now, we aren't using any reducers - but, I'm keeping this here as a draft + * just in case we need them later on. As a final cleanup, we could remove this if we never + * end up using reducers + */ + setLoading: ( + state: WritableDraft, + action: PayloadAction + ) => { + state.output.loading = action.payload; + }, +}; diff --git a/src/plugins/navigation_embeddable/public/embeddable/types.ts b/src/plugins/navigation_embeddable/public/embeddable/types.ts new file mode 100644 index 0000000000000..40dd5901db948 --- /dev/null +++ b/src/plugins/navigation_embeddable/public/embeddable/types.ts @@ -0,0 +1,70 @@ +/* + * 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 { DashboardAttributes } from '@kbn/dashboard-plugin/common'; +import { ReduxEmbeddableState } from '@kbn/presentation-util-plugin/public'; +import { EmbeddableInput, EmbeddableOutput } from '@kbn/embeddable-plugin/public'; + +import { ExternalLinkEmbeddableStrings } from '../components/external_link/external_link_strings'; +import { DashboardLinkEmbeddableStrings } from '../components/dashboard_link/dashboard_link_strings'; + +/** + * Dashboard to dashboard links + */ +export const DASHBOARD_LINK_TYPE = 'dashboardLink'; +export interface DashboardItem { + id: string; + attributes: DashboardAttributes; +} + +/** + * External URL links + */ +export const EXTERNAL_LINK_TYPE = 'externalLink'; + +/** + * Navigation embeddable explicit input + */ +export type NavigationLinkType = typeof DASHBOARD_LINK_TYPE | typeof EXTERNAL_LINK_TYPE; + +export interface NavigationEmbeddableLink { + type: NavigationLinkType; + destination: string; + // order: number; TODO: Use this as part of https://github.com/elastic/kibana/issues/154361 + label?: string; +} + +export interface NavigationEmbeddableInput extends EmbeddableInput { + links: { [id: string]: NavigationEmbeddableLink }; +} + +export const NavigationLinkInfo: { + [id in NavigationLinkType]: { icon: string; displayName: string; description: string }; +} = { + [DASHBOARD_LINK_TYPE]: { + icon: 'dashboardApp', + displayName: DashboardLinkEmbeddableStrings.getDisplayName(), + description: DashboardLinkEmbeddableStrings.getDescription(), + }, + [EXTERNAL_LINK_TYPE]: { + icon: 'link', + displayName: ExternalLinkEmbeddableStrings.getDisplayName(), + description: ExternalLinkEmbeddableStrings.getDescription(), + }, +}; + +/** + * Navigation embeddable redux state + */ +// export interface NavigationEmbeddableComponentState {} // TODO: Uncomment this if we end up needing component state + +export type NavigationEmbeddableReduxState = ReduxEmbeddableState< + NavigationEmbeddableInput, + EmbeddableOutput, + {} // We currently don't have any component state - TODO: Replace with `NavigationEmbeddableComponentState` if necessary +>; diff --git a/src/plugins/navigation_embeddable/public/index.ts b/src/plugins/navigation_embeddable/public/index.ts index 5e8be17c8958b..9cdbcfcc6c667 100644 --- a/src/plugins/navigation_embeddable/public/index.ts +++ b/src/plugins/navigation_embeddable/public/index.ts @@ -6,12 +6,12 @@ * Side Public License, v 1. */ -export type { NavigationEmbeddableFactory } from './navigation_embeddable'; +export type { NavigationEmbeddableFactory } from './embeddable'; export { NAVIGATION_EMBEDDABLE_TYPE, NavigationEmbeddableFactoryDefinition, NavigationEmbeddable, -} from './navigation_embeddable'; +} from './embeddable'; import { NavigationEmbeddablePlugin } from './plugin'; diff --git a/src/plugins/navigation_embeddable/public/navigation_embeddable/navigation_embeddable.tsx b/src/plugins/navigation_embeddable/public/navigation_embeddable/navigation_embeddable.tsx deleted file mode 100644 index 2c66f4b655fac..0000000000000 --- a/src/plugins/navigation_embeddable/public/navigation_embeddable/navigation_embeddable.tsx +++ /dev/null @@ -1,32 +0,0 @@ -/* - * 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 { EuiTitle } from '@elastic/eui'; -import { Embeddable } from '@kbn/embeddable-plugin/public'; -import type { EmbeddableInput, IContainer } from '@kbn/embeddable-plugin/public'; - -export const NAVIGATION_EMBEDDABLE_TYPE = 'navigation'; - -export class NavigationEmbeddable extends Embeddable { - public readonly type = NAVIGATION_EMBEDDABLE_TYPE; - - constructor(initialInput: EmbeddableInput, parent?: IContainer) { - super(initialInput, {}, parent); - } - - public render(el: HTMLElement) { - return ( - -

Call me Magellan, cuz I'm a navigator!

-
- ); - } - - public reload() {} -} diff --git a/src/plugins/navigation_embeddable/public/navigation_embeddable/navigation_embeddable_factory.ts b/src/plugins/navigation_embeddable/public/navigation_embeddable/navigation_embeddable_factory.ts deleted file mode 100644 index 54f8ecbb9f892..0000000000000 --- a/src/plugins/navigation_embeddable/public/navigation_embeddable/navigation_embeddable_factory.ts +++ /dev/null @@ -1,36 +0,0 @@ -/* - * 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 { i18n } from '@kbn/i18n'; -import type { EmbeddableInput, IContainer } from '@kbn/embeddable-plugin/public'; -import { EmbeddableFactory, EmbeddableFactoryDefinition } from '@kbn/embeddable-plugin/public'; -import { NavigationEmbeddable, NAVIGATION_EMBEDDABLE_TYPE } from './navigation_embeddable'; - -export type NavigationEmbeddableFactory = EmbeddableFactory; - -export class NavigationEmbeddableFactoryDefinition implements EmbeddableFactoryDefinition { - public readonly type = NAVIGATION_EMBEDDABLE_TYPE; - - public async isEditable() { - return true; - } - - public async create(initialInput: EmbeddableInput, parent?: IContainer) { - return new NavigationEmbeddable(initialInput, parent); - } - - public getDisplayName() { - return i18n.translate('navigationEmbeddable.navigationEmbeddableFactory.displayName', { - defaultMessage: 'Navigation', - }); - } - - public getIconType() { - return 'link'; - } -} diff --git a/src/plugins/navigation_embeddable/public/plugin.ts b/src/plugins/navigation_embeddable/public/plugin.ts index 23dff1f5d1eb8..7a969c0298f7a 100644 --- a/src/plugins/navigation_embeddable/public/plugin.ts +++ b/src/plugins/navigation_embeddable/public/plugin.ts @@ -6,33 +6,48 @@ * Side Public License, v 1. */ +import { DashboardStart } from '@kbn/dashboard-plugin/public'; import { CoreSetup, CoreStart, Plugin } from '@kbn/core/public'; import { EmbeddableSetup, EmbeddableStart } from '@kbn/embeddable-plugin/public'; -import { NAVIGATION_EMBEDDABLE_TYPE } from './navigation_embeddable'; -import { NavigationEmbeddableFactoryDefinition } from './navigation_embeddable'; -export interface SetupDependencies { +import { NAVIGATION_EMBEDDABLE_TYPE } from './embeddable'; +import { setKibanaServices } from './services/kibana_services'; +import { NavigationEmbeddableFactoryDefinition } from './embeddable'; + +export interface NavigationEmbeddableSetupDependencies { embeddable: EmbeddableSetup; } -export interface StartDependencies { +export interface NavigationEmbeddableStartDependencies { embeddable: EmbeddableStart; + dashboard: DashboardStart; } export class NavigationEmbeddablePlugin - implements Plugin + implements + Plugin< + void, + void, + NavigationEmbeddableSetupDependencies, + NavigationEmbeddableStartDependencies + > { constructor() {} - public setup(core: CoreSetup, plugins: SetupDependencies) { - plugins.embeddable.registerEmbeddableFactory( - NAVIGATION_EMBEDDABLE_TYPE, - new NavigationEmbeddableFactoryDefinition() - ); + public setup( + core: CoreSetup, + plugins: NavigationEmbeddableSetupDependencies + ) { + core.getStartServices().then(([_, deps]) => { + plugins.embeddable.registerEmbeddableFactory( + NAVIGATION_EMBEDDABLE_TYPE, + new NavigationEmbeddableFactoryDefinition() + ); + }); } - public start(core: CoreStart, plugins: StartDependencies) { - return {}; + public start(core: CoreStart, plugins: NavigationEmbeddableStartDependencies) { + setKibanaServices(core, plugins); } public stop() {} diff --git a/src/plugins/navigation_embeddable/public/services/kibana_services.ts b/src/plugins/navigation_embeddable/public/services/kibana_services.ts new file mode 100644 index 0000000000000..710c6227a3568 --- /dev/null +++ b/src/plugins/navigation_embeddable/public/services/kibana_services.ts @@ -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 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 { BehaviorSubject } from 'rxjs'; + +import { CoreStart } from '@kbn/core/public'; +import { DashboardStart } from '@kbn/dashboard-plugin/public'; + +import { NavigationEmbeddableStartDependencies } from '../plugin'; + +export let coreServices: CoreStart; +export let dashboardServices: DashboardStart; + +const servicesReady$ = new BehaviorSubject(false); + +export const untilPluginStartServicesReady = () => { + if (servicesReady$.value) return Promise.resolve(); + return new Promise((resolve) => { + const subscription = servicesReady$.subscribe((isInitialized) => { + if (isInitialized) { + subscription.unsubscribe(); + resolve(); + } + }); + }); +}; + +export const setKibanaServices = ( + kibanaCore: CoreStart, + deps: NavigationEmbeddableStartDependencies +) => { + coreServices = kibanaCore; + dashboardServices = deps.dashboard; + + servicesReady$.next(true); +}; diff --git a/src/plugins/navigation_embeddable/tsconfig.json b/src/plugins/navigation_embeddable/tsconfig.json index 05ce2326f9768..a7ea3f209f7ad 100644 --- a/src/plugins/navigation_embeddable/tsconfig.json +++ b/src/plugins/navigation_embeddable/tsconfig.json @@ -1,15 +1,16 @@ { "extends": "../../../tsconfig.base.json", "compilerOptions": { - "outDir": "target/types", + "outDir": "target/types" }, - "include": ["public/**/*", "common/**/*", "server/**/*"], + "include": ["public/**/*", "common/**/*", "server/**/*", "public/**/*.json"], "kbn_references": [ "@kbn/core", - "@kbn/embeddable-plugin", "@kbn/i18n", + "@kbn/dashboard-plugin", + "@kbn/embeddable-plugin", + "@kbn/kibana-react-plugin", + "@kbn/presentation-util-plugin", ], - "exclude": [ - "target/**/*", - ] + "exclude": ["target/**/*"] }