[8.x] [Dashboard Navigation] Swap SASS for Emotion (#211124) (#212102)

# Backport

This will backport the following commits from `main` to `8.x`:
- [[Dashboard Navigation] Swap SASS for Emotion
(#211124)](https://github.com/elastic/kibana/pull/211124)

<!--- Backport version: 9.6.6 -->

### Questions ?
Please refer to the [Backport tool
documentation](https://github.com/sorenlouv/backport)

<!--BACKPORT [{"author":{"name":"Hannah
Mudge","email":"Heenawter@users.noreply.github.com"},"sourceCommit":{"committedDate":"2025-02-21T15:07:23Z","message":"[Dashboard
Navigation] Swap SASS for Emotion (#211124)\n\nPart of
https://github.com/elastic/kibana/issues/207852\n\n## Summary\n\nThis PR
migrates all `*.scss` files in the Links plugin to Emotion.\nTesting
should simply verify that this PR does not introduce any
style\nchanges.\n\n\n### Checklist\n\n- [x] [Unit or
functional\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\nwere
updated or added to match the most common scenario\n- [x] The PR
description includes the appropriate Release Notes section,\nand the
correct `release_note:*` label is applied per
the\n[guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)\n\n###
Identify risks\n\nAny risks associated with this PR are purely cosmetic,
since it contains\nexclusively style-related
changes.\n\n---------\n\nCo-authored-by: kibanamachine
<42973632+kibanamachine@users.noreply.github.com>","sha":"85f4f4d5b4bdd2a91f1138cd5f660c88d729a3c8","branchLabelMapping":{"^v9.1.0$":"main","^v8.19.0$":"8.x","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["Team:Presentation","loe:small","release_note:skip","impact:high","Project:Dashboard
Navigation","backport:version","v9.1.0","v8.19.0"],"title":"[Dashboard
Navigation] Swap SASS for
Emotion","number":211124,"url":"https://github.com/elastic/kibana/pull/211124","mergeCommit":{"message":"[Dashboard
Navigation] Swap SASS for Emotion (#211124)\n\nPart of
https://github.com/elastic/kibana/issues/207852\n\n## Summary\n\nThis PR
migrates all `*.scss` files in the Links plugin to Emotion.\nTesting
should simply verify that this PR does not introduce any
style\nchanges.\n\n\n### Checklist\n\n- [x] [Unit or
functional\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\nwere
updated or added to match the most common scenario\n- [x] The PR
description includes the appropriate Release Notes section,\nand the
correct `release_note:*` label is applied per
the\n[guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)\n\n###
Identify risks\n\nAny risks associated with this PR are purely cosmetic,
since it contains\nexclusively style-related
changes.\n\n---------\n\nCo-authored-by: kibanamachine
<42973632+kibanamachine@users.noreply.github.com>","sha":"85f4f4d5b4bdd2a91f1138cd5f660c88d729a3c8"}},"sourceBranch":"main","suggestedTargetBranches":["8.x"],"targetPullRequestStates":[{"branch":"main","label":"v9.1.0","branchLabelMappingKey":"^v9.1.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/211124","number":211124,"mergeCommit":{"message":"[Dashboard
Navigation] Swap SASS for Emotion (#211124)\n\nPart of
https://github.com/elastic/kibana/issues/207852\n\n## Summary\n\nThis PR
migrates all `*.scss` files in the Links plugin to Emotion.\nTesting
should simply verify that this PR does not introduce any
style\nchanges.\n\n\n### Checklist\n\n- [x] [Unit or
functional\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\nwere
updated or added to match the most common scenario\n- [x] The PR
description includes the appropriate Release Notes section,\nand the
correct `release_note:*` label is applied per
the\n[guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)\n\n###
Identify risks\n\nAny risks associated with this PR are purely cosmetic,
since it contains\nexclusively style-related
changes.\n\n---------\n\nCo-authored-by: kibanamachine
<42973632+kibanamachine@users.noreply.github.com>","sha":"85f4f4d5b4bdd2a91f1138cd5f660c88d729a3c8"}},{"branch":"8.x","label":"v8.19.0","branchLabelMappingKey":"^v8.19.0$","isSourceBranch":false,"state":"NOT_CREATED"}]}]
BACKPORT-->

Co-authored-by: Hannah Mudge <Heenawter@users.noreply.github.com>
This commit is contained in:
Kibana Machine 2025-02-22 04:04:48 +11:00 committed by GitHub
parent bd69e3602a
commit 75ca9340ca
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 408 additions and 442 deletions

View file

@ -1,38 +0,0 @@
@import '../../../../../core/public/mixins';
@keyframes euiFlyoutOpenAnimation {
0% {
opacity: 0;
transform: translateX(100%);
}
100% {
opacity: 1;
transform: translateX(0%);
}
}
@keyframes euiFlyoutCloseAnimation {
0% {
opacity: 1;
transform: translateX(0%);
}
100% {
opacity: 0;
transform: translateX(100%);
}
}
@mixin euiFlyout {
height: calc(100vh - var(--euiFixedHeadersOffset, 0));
position: fixed;
display: flex;
inline-size: 50vw;
z-index: $euiZFlyout;
align-items: stretch;
flex-direction: column;
border-left: $euiBorderThin;
background: $euiColorEmptyShade;
min-width: ($euiSizeXL * 13) + $euiSizeS; // 424px
}

View file

@ -14,12 +14,13 @@ import { createEvent, fireEvent, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { LINKS_VERTICAL_LAYOUT } from '../../../common/content_management';
import { DashboardLinkComponent } from './dashboard_link_component';
import { DashboardLinkComponent, DashboardLinkProps } from './dashboard_link_component';
import { DashboardLinkStrings } from './dashboard_link_strings';
import { getMockLinksParentApi } from '../../mocks';
import { ResolvedLink } from '../../types';
import { BehaviorSubject } from 'rxjs';
import { AggregateQuery, Filter, Query, TimeRange } from '@kbn/es-query';
import { EuiThemeProvider } from '@elastic/eui';
function createMockLinksParent({
initialQuery,
@ -52,6 +53,37 @@ describe('Dashboard link component', () => {
description: 'Dashboard 1 description',
};
const renderComponent = (overrides?: Partial<DashboardLinkProps>) => {
const parentApi = createMockLinksParent({});
const { rerender, ...rtlRest } = render(
<EuiThemeProvider>
<DashboardLinkComponent
link={resolvedLink}
layout={LINKS_VERTICAL_LAYOUT}
parentApi={parentApi}
{...overrides}
/>
</EuiThemeProvider>
);
return {
...rtlRest,
rerender: (newOverrides: Partial<DashboardLinkProps>) => {
return rerender(
<EuiThemeProvider>
<DashboardLinkComponent
link={resolvedLink}
layout={LINKS_VERTICAL_LAYOUT}
parentApi={parentApi}
{...overrides}
{...newOverrides}
/>
</EuiThemeProvider>
);
},
};
};
beforeEach(async () => {
window.open = jest.fn();
});
@ -62,13 +94,7 @@ describe('Dashboard link component', () => {
test('by default uses navigate to open in same tab', async () => {
const parentApi = createMockLinksParent({});
render(
<DashboardLinkComponent
link={resolvedLink}
layout={LINKS_VERTICAL_LAYOUT}
parentApi={parentApi}
/>
);
renderComponent({ parentApi });
// renders dashboard title
const link = screen.getByTestId('dashboardLink--foo');
@ -92,14 +118,7 @@ describe('Dashboard link component', () => {
});
test('modified click does not trigger event.preventDefault', async () => {
const parentApi = createMockLinksParent({});
render(
<DashboardLinkComponent
link={resolvedLink}
layout={LINKS_VERTICAL_LAYOUT}
parentApi={parentApi}
/>
);
renderComponent();
const link = screen.getByTestId('dashboardLink--foo');
const clickEvent = createEvent.click(link, { ctrlKey: true });
const preventDefault = jest.spyOn(clickEvent, 'preventDefault');
@ -109,16 +128,14 @@ describe('Dashboard link component', () => {
test('openInNewTab uses window.open, not navigateToApp, and renders external icon', async () => {
const parentApi = createMockLinksParent({});
render(
<DashboardLinkComponent
link={{
...resolvedLink,
options: { ...DEFAULT_DASHBOARD_DRILLDOWN_OPTIONS, openInNewTab: true },
}}
layout={LINKS_VERTICAL_LAYOUT}
parentApi={parentApi}
/>
);
renderComponent({
link: {
...resolvedLink,
options: { ...DEFAULT_DASHBOARD_DRILLDOWN_OPTIONS, openInNewTab: true },
},
parentApi,
});
const link = screen.getByTestId('dashboardLink--foo');
expect(link).toBeInTheDocument();
// external link icon is rendered
@ -149,16 +166,14 @@ describe('Dashboard link component', () => {
to: 'now',
});
render(
<DashboardLinkComponent
link={{
...resolvedLink,
options: DEFAULT_DASHBOARD_DRILLDOWN_OPTIONS,
}}
layout={LINKS_VERTICAL_LAYOUT}
parentApi={parentApi}
/>
);
renderComponent({
link: {
...resolvedLink,
options: DEFAULT_DASHBOARD_DRILLDOWN_OPTIONS,
},
parentApi,
});
expect(parentApi.locator?.getRedirectUrl).toBeCalledWith({
dashboardId: '456',
timeRange: { from: 'now-7d', to: 'now' },
@ -185,19 +200,17 @@ describe('Dashboard link component', () => {
to: 'now',
});
render(
<DashboardLinkComponent
link={{
...resolvedLink,
options: {
...DEFAULT_DASHBOARD_DRILLDOWN_OPTIONS,
useCurrentDateRange: false,
},
}}
layout={LINKS_VERTICAL_LAYOUT}
parentApi={parentApi}
/>
);
renderComponent({
link: {
...resolvedLink,
options: {
...DEFAULT_DASHBOARD_DRILLDOWN_OPTIONS,
useCurrentDateRange: false,
},
},
parentApi,
});
expect(parentApi.locator?.getRedirectUrl).toBeCalledWith({
dashboardId: '456',
filters: initialFilters,
@ -223,19 +236,17 @@ describe('Dashboard link component', () => {
to: 'now',
});
render(
<DashboardLinkComponent
link={{
...resolvedLink,
options: {
...DEFAULT_DASHBOARD_DRILLDOWN_OPTIONS,
useCurrentFilters: false,
},
}}
layout={LINKS_VERTICAL_LAYOUT}
parentApi={parentApi}
/>
);
renderComponent({
link: {
...resolvedLink,
options: {
...DEFAULT_DASHBOARD_DRILLDOWN_OPTIONS,
useCurrentFilters: false,
},
},
parentApi,
});
expect(parentApi.locator?.getRedirectUrl).toBeCalledWith({
dashboardId: '456',
timeRange: { from: 'now-7d', to: 'now' },
@ -244,19 +255,14 @@ describe('Dashboard link component', () => {
});
test('shows an error when fetchDashboard fails', async () => {
const parentApi = createMockLinksParent({});
renderComponent({
link: {
...resolvedLink,
title: 'Error fetching dashboard',
error: new Error('not found'),
},
});
render(
<DashboardLinkComponent
link={{
...resolvedLink,
title: 'Error fetching dashboard',
error: new Error('not found'),
}}
layout={LINKS_VERTICAL_LAYOUT}
parentApi={parentApi}
/>
);
const link = await screen.findByTestId('dashboardLink--foo--error');
expect(link).toHaveTextContent(DashboardLinkStrings.getDashboardErrorLabel());
});
@ -266,17 +272,14 @@ describe('Dashboard link component', () => {
parentApi.savedObjectId$ = new BehaviorSubject<string | undefined>('123');
parentApi.title$ = new BehaviorSubject<string | undefined>('current dashboard');
render(
<DashboardLinkComponent
link={{
...resolvedLink,
destination: '123',
id: 'bar',
}}
layout={LINKS_VERTICAL_LAYOUT}
parentApi={parentApi}
/>
);
renderComponent({
link: {
...resolvedLink,
destination: '123',
id: 'bar',
},
parentApi,
});
const link = screen.getByTestId('dashboardLink--bar');
expect(link).toHaveTextContent('current dashboard');
@ -286,19 +289,13 @@ describe('Dashboard link component', () => {
});
test('shows dashboard title and description in tooltip', async () => {
const parentApi = createMockLinksParent({});
render(
<DashboardLinkComponent
link={{
...resolvedLink,
title: 'another dashboard',
description: 'something awesome',
}}
layout={LINKS_VERTICAL_LAYOUT}
parentApi={parentApi}
/>
);
renderComponent({
link: {
...resolvedLink,
title: 'another dashboard',
description: 'something awesome',
},
});
const link = screen.getByTestId('dashboardLink--foo');
await userEvent.hover(link);
@ -315,48 +312,38 @@ describe('Dashboard link component', () => {
savedObjectId$: new BehaviorSubject<string | undefined>('123'),
};
const { rerender } = render(
<DashboardLinkComponent
link={{
...resolvedLink,
destination: '123',
id: 'bar',
}}
layout={LINKS_VERTICAL_LAYOUT}
parentApi={parentApi}
/>
);
const { rerender } = renderComponent({
link: {
...resolvedLink,
destination: '123',
id: 'bar',
},
parentApi,
});
expect(await screen.findByTestId('dashboardLink--bar')).toHaveTextContent('old title');
parentApi.title$.next('new title');
rerender(
<DashboardLinkComponent
link={{
...resolvedLink,
destination: '123',
id: 'bar',
label: undefined,
}}
layout={LINKS_VERTICAL_LAYOUT}
parentApi={parentApi}
/>
);
rerender({
link: {
...resolvedLink,
destination: '123',
id: 'bar',
label: undefined,
},
});
expect(await screen.findByTestId('dashboardLink--bar')).toHaveTextContent('new title');
});
test('can override link label', async () => {
const label = 'my custom label';
const parentApi = createMockLinksParent({});
render(
<DashboardLinkComponent
link={{
...resolvedLink,
label,
}}
layout={LINKS_VERTICAL_LAYOUT}
parentApi={parentApi}
/>
);
renderComponent({
link: {
...resolvedLink,
label,
},
});
const link = screen.getByTestId('dashboardLink--foo');
expect(link).toHaveTextContent(label);
await userEvent.hover(link);
@ -369,18 +356,15 @@ describe('Dashboard link component', () => {
const parentApi = createMockLinksParent({});
parentApi.savedObjectId$ = new BehaviorSubject<string | undefined>('123');
render(
<DashboardLinkComponent
link={{
...resolvedLink,
destination: '123',
id: 'bar',
label: customLabel,
}}
layout={LINKS_VERTICAL_LAYOUT}
parentApi={parentApi}
/>
);
renderComponent({
link: {
...resolvedLink,
destination: '123',
id: 'bar',
label: customLabel,
},
parentApi,
});
const link = screen.getByTestId('dashboardLink--bar');
expect(link).toHaveTextContent(customLabel);

View file

@ -10,34 +10,33 @@
import classNames from 'classnames';
import React, { useMemo } from 'react';
import { EuiListGroupItem } from '@elastic/eui';
import { EuiListGroupItem, UseEuiTheme } from '@elastic/eui';
import { css } from '@emotion/react';
import { METRIC_TYPE } from '@kbn/analytics';
import { DashboardLocatorParams } from '@kbn/dashboard-plugin/public';
import {
DashboardDrilldownOptions,
DEFAULT_DASHBOARD_DRILLDOWN_OPTIONS,
} from '@kbn/presentation-util-plugin/public';
import { Query, isFilterPinned } from '@kbn/es-query';
import { useBatchedPublishingSubjects } from '@kbn/presentation-publishing';
import {
DEFAULT_DASHBOARD_DRILLDOWN_OPTIONS,
DashboardDrilldownOptions,
} from '@kbn/presentation-util-plugin/public';
import { isFilterPinned, Query } from '@kbn/es-query';
import {
DASHBOARD_LINK_TYPE,
LinksLayoutType,
LINKS_VERTICAL_LAYOUT,
LinksLayoutType,
} from '../../../common/content_management';
import { trackUiMetric } from '../../services/kibana_services';
import { DashboardLinkStrings } from './dashboard_link_strings';
import { LinksParentApi, ResolvedLink } from '../../types';
import { DashboardLinkStrings } from './dashboard_link_strings';
export const DashboardLinkComponent = ({
link,
layout,
parentApi,
}: {
export interface DashboardLinkProps {
link: ResolvedLink;
layout: LinksLayoutType;
parentApi: LinksParentApi;
}) => {
}
export const DashboardLinkComponent = ({ link, layout, parentApi }: DashboardLinkProps) => {
const [
parentDashboardId,
parentDashboardTitle,
@ -155,6 +154,7 @@ export const DashboardLinkComponent = ({
color="text"
{...onClickProps}
id={id}
css={styles}
showToolTip={true}
toolTipProps={{
title: tooltipTitle,
@ -179,3 +179,46 @@ export const DashboardLinkComponent = ({
/>
);
};
const styles = ({ euiTheme }: UseEuiTheme) =>
css({
// universal current dashboard link styles
'&.linkCurrent': {
borderRadius: 0,
cursor: 'default',
'& .euiListGroupItem__text': {
color: euiTheme.colors.textPrimary,
},
},
// vertical layout - current dashboard link styles
'.verticalLayoutWrapper &.linkCurrent::before': {
// add left border for current dashboard
content: "''",
position: 'absolute',
height: '75%',
width: `calc(.5 * ${euiTheme.size.xs})`,
backgroundColor: euiTheme.colors.primary,
},
// horizontal layout - current dashboard link styles
'.horizontalLayoutWrapper &.linkCurrent': {
padding: `0 ${euiTheme.size.s}`,
'& .euiListGroupItem__text': {
// add bottom border for current dashboard
boxShadow: `${euiTheme.colors.textPrimary} 0 calc(-.5 * ${euiTheme.size.xs}) inset`,
paddingInline: 0,
},
},
// dashboard not found error styles
'&.dashboardLinkError': {
'&.dashboardLinkError--noLabel .euiListGroupItem__text': {
fontStyle: 'italic',
},
'.dashboardLinkIcon': {
marginRight: euiTheme.size.s,
},
},
});

View file

@ -21,6 +21,7 @@ import {
EuiFlexGroup,
EuiComboBoxOptionOption,
} from '@elastic/eui';
import { css } from '@emotion/react';
import { DashboardItem } from '../../types';
import { DashboardLinkStrings } from './dashboard_link_strings';
@ -100,7 +101,7 @@ export const DashboardLinkDestinationPicker = ({
return (
<EuiFlexGroup gutterSize="s" alignItems="center" className={contentClassName}>
{dashboardId === parentDashboardId && (
<EuiFlexItem grow={false}>
<EuiFlexItem grow={false} className={'linksDashboardItem--current'}>
<EuiBadge>{DashboardLinkStrings.getCurrentDashboardLabel()}</EuiBadge>
</EuiFlexItem>
)}
@ -143,6 +144,25 @@ export const DashboardLinkDestinationPicker = ({
}
}}
data-test-subj="links--linkEditor--dashboardLink--comboBox"
inputPopoverProps={{ panelProps: { css: styles } }}
/>
);
};
const styles = css({
'.linksDashboardItem': {
'.linksDashboardItem--current': {
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': {
textDecoration: 'none !important',
},
'.linksPanelEditorLinkText': {
'&:hover': {
textDecoration: 'underline !important',
},
},
},
});

View file

@ -1,80 +0,0 @@
@import '../../mixins';
.linksPanelEditor {
.linkEditor {
@include euiFlyout;
max-inline-size: $euiSizeXS * 125; // 4px * 125 = 500px
&.in {
animation: euiFlyoutOpenAnimation $euiAnimSpeedNormal $euiAnimSlightResistance;
}
&.out {
animation: euiFlyoutCloseAnimation $euiAnimSpeedNormal $euiAnimSlightResistance;
}
.linkEditorBackButton {
height: auto;
}
}
}
.linksDashboardItem {
.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;
}
.linksPanelEditorLinkText {
&:hover {
text-decoration: underline !important;
}
}
}
.linksPanelEditorLink {
padding: $euiSizeXS $euiSizeS;
color: $euiTextColor;
.linksPanelEditorLinkText {
flex: 1;
min-width: 0;
}
&.linkError {
border: 1px solid transparentize($euiColorWarningText, .7);
.linksPanelEditorLinkText {
color: $euiColorWarningText;
}
.linksPanelEditorLinkText--noLabel {
font-style: italic;
}
}
.links_hoverActions {
background-color: $euiColorEmptyShade;
position: absolute;
right: $euiSizeL;
opacity: 0;
visibility: hidden;
transition: visibility $euiAnimSpeedNormal, opacity $euiAnimSpeedNormal;
}
&:hover, &:focus-within {
.links_hoverActions {
opacity: 1;
visibility: visible;
}
}
}
.linksDroppableLinksArea {
margin: 0 (-$euiSizeXS);
}

View file

@ -10,20 +10,13 @@
import React from 'react';
import userEvent from '@testing-library/user-event';
import { render, screen, waitFor } from '@testing-library/react';
import LinksEditor from './links_editor';
import { EuiThemeProvider } from '@elastic/eui';
import LinksEditor, { LinksEditorProps } from './links_editor';
import { LinksStrings } from '../links_strings';
import { LINKS_VERTICAL_LAYOUT } from '../../../common/content_management';
import { ResolvedLink } from '../../types';
describe('LinksEditor', () => {
const defaultProps = {
onSaveToLibrary: jest.fn().mockImplementation(() => Promise.resolve()),
onAddToDashboard: jest.fn(),
onClose: jest.fn(),
isByReference: false,
flyoutId: 'test-id',
};
const someLinks: ResolvedLink[] = [
{
id: 'foo',
@ -60,8 +53,24 @@ describe('LinksEditor', () => {
jest.clearAllMocks();
});
const renderEditor = (overrides?: Partial<LinksEditorProps>) => {
const defaultProps = {
onSaveToLibrary: jest.fn().mockImplementation(() => Promise.resolve()),
onAddToDashboard: jest.fn(),
onClose: jest.fn(),
isByReference: false,
flyoutId: 'test-id',
};
return render(
<EuiThemeProvider>
<LinksEditor {...defaultProps} {...overrides} />
</EuiThemeProvider>
);
};
test('shows empty state with no links', async () => {
render(<LinksEditor {...defaultProps} />);
const onClose = jest.fn();
renderEditor({ onClose });
expect(screen.getByTestId('links--panelEditor--title')).toHaveTextContent(
LinksStrings.editor.panelEditor.getCreateFlyoutTitle()
);
@ -69,12 +78,13 @@ describe('LinksEditor', () => {
expect(screen.getByTestId('links--panelEditor--saveBtn')).toBeDisabled();
await userEvent.click(screen.getByTestId('links--panelEditor--closeBtn'));
expect(defaultProps.onClose).toHaveBeenCalledTimes(1);
expect(onClose).toHaveBeenCalledTimes(1);
});
test('shows links in order', async () => {
const expectedLinkIds = [...someLinks].sort((a, b) => a.order - b.order).map(({ id }) => id);
render(<LinksEditor {...defaultProps} initialLinks={someLinks} />);
renderEditor({ initialLinks: someLinks });
expect(screen.getByTestId('links--panelEditor--title')).toHaveTextContent(
LinksStrings.editor.panelEditor.getEditFlyoutTitle()
);
@ -88,19 +98,23 @@ describe('LinksEditor', () => {
test('saving by reference panels calls onSaveToLibrary', async () => {
const orderedLinks = [...someLinks].sort((a, b) => a.order - b.order);
render(<LinksEditor {...defaultProps} initialLinks={someLinks} isByReference />);
const onSaveToLibrary = jest.fn().mockImplementation(() => Promise.resolve());
renderEditor({ initialLinks: someLinks, onSaveToLibrary, isByReference: true });
const saveButton = screen.getByTestId('links--panelEditor--saveBtn');
await userEvent.click(saveButton);
await waitFor(() => expect(defaultProps.onSaveToLibrary).toHaveBeenCalledTimes(1));
expect(defaultProps.onSaveToLibrary).toHaveBeenCalledWith(orderedLinks, LINKS_VERTICAL_LAYOUT);
await waitFor(() => expect(onSaveToLibrary).toHaveBeenCalledTimes(1));
expect(onSaveToLibrary).toHaveBeenCalledWith(orderedLinks, LINKS_VERTICAL_LAYOUT);
});
test('saving by value panel calls onAddToDashboard', async () => {
const orderedLinks = [...someLinks].sort((a, b) => a.order - b.order);
render(<LinksEditor {...defaultProps} initialLinks={someLinks} isByReference={false} />);
const onAddToDashboard = jest.fn();
renderEditor({ initialLinks: someLinks, onAddToDashboard, isByReference: false });
const saveButton = screen.getByTestId('links--panelEditor--saveBtn');
await userEvent.click(saveButton);
expect(defaultProps.onAddToDashboard).toHaveBeenCalledTimes(1);
expect(defaultProps.onAddToDashboard).toHaveBeenCalledWith(orderedLinks, LINKS_VERTICAL_LAYOUT);
expect(onAddToDashboard).toHaveBeenCalledTimes(1);
expect(onAddToDashboard).toHaveBeenCalledWith(orderedLinks, LINKS_VERTICAL_LAYOUT);
});
});

View file

@ -29,26 +29,25 @@ import {
EuiFormRow,
EuiSwitch,
EuiTitle,
UseEuiTheme,
} from '@elastic/eui';
import { css, keyframes } from '@emotion/react';
import {
LinksLayoutType,
LINKS_HORIZONTAL_LAYOUT,
LINKS_VERTICAL_LAYOUT,
LinksLayoutType,
} from '../../../common/content_management';
import { focusMainFlyout } from '../../editor/links_editor_tools';
import { openLinkEditorFlyout } from '../../editor/open_link_editor_flyout';
import { getOrderedLinkList } from '../../lib/resolve_links';
import { coreServices } from '../../services/kibana_services';
import { ResolvedLink } from '../../types';
import { LinksStrings } from '../links_strings';
import { TooltipWrapper } from '../tooltip_wrapper';
import { LinksEditorEmptyPrompt } from './links_editor_empty_prompt';
import { LinksEditorSingleLink } from './links_editor_single_link';
import { TooltipWrapper } from '../tooltip_wrapper';
import './links_editor.scss';
import { ResolvedLink } from '../../types';
import { getOrderedLinkList } from '../../lib/resolve_links';
const layoutOptions: EuiButtonGroupOptionProps[] = [
{
id: LINKS_VERTICAL_LAYOUT,
@ -62,6 +61,17 @@ const layoutOptions: EuiButtonGroupOptionProps[] = [
},
];
export interface LinksEditorProps {
onSaveToLibrary: (newLinks: ResolvedLink[], newLayout: LinksLayoutType) => Promise<void>;
onAddToDashboard: (newLinks: ResolvedLink[], newLayout: LinksLayoutType) => void;
onClose: () => void;
initialLinks?: ResolvedLink[];
initialLayout?: LinksLayoutType;
parentDashboardId?: string;
isByReference: boolean;
flyoutId: string; // used to manage the focus of this flyout after individual link editor flyout is closed
}
const LinksEditor = ({
onSaveToLibrary,
onAddToDashboard,
@ -71,16 +81,7 @@ const LinksEditor = ({
parentDashboardId,
isByReference,
flyoutId,
}: {
onSaveToLibrary: (newLinks: ResolvedLink[], newLayout: LinksLayoutType) => Promise<void>;
onAddToDashboard: (newLinks: ResolvedLink[], newLayout: LinksLayoutType) => void;
onClose: () => void;
initialLinks?: ResolvedLink[];
initialLayout?: LinksLayoutType;
parentDashboardId?: string;
isByReference: boolean;
flyoutId: string; // used to manage the focus of this flyout after individual link editor flyout is closed
}) => {
}: LinksEditorProps) => {
const toasts = coreServices.notifications.toasts;
const isMounted = useMountedState();
const editLinkFlyoutRef = useRef<HTMLDivElement>(null);
@ -163,7 +164,7 @@ const LinksEditor = ({
return (
<>
<div ref={editLinkFlyoutRef} />
<div css={styles.flyoutStyles} ref={editLinkFlyoutRef} />
<EuiFlyoutHeader hasBorder>
<EuiFlexGroup alignItems="center">
<EuiFlexItem grow={false}>
@ -177,12 +178,7 @@ const LinksEditor = ({
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutHeader>
<EuiFlyoutBody
// EUI TODO: We need to set transform to 'none' to avoid drag/drop issues in the flyout caused by the
// `transform: translateZ(0)` workaround for the mask image bug in Chromium.
// https://github.com/elastic/eui/pull/7855.
css={{ '.euiFlyoutBody__overflow': { transform: 'none' } }}
>
<EuiFlyoutBody css={styles.bodyStyles}>
<EuiForm fullWidth>
<EuiFormRow label={LinksStrings.editor.panelEditor.getLayoutSettingsTitle()}>
<EuiButtonGroup
@ -205,7 +201,7 @@ const LinksEditor = ({
<>
<EuiDragDropContext onDragEnd={onDragEnd}>
<EuiDroppable
className="linksDroppableLinksArea"
css={styles.droppableStyles}
droppableId="linksDroppableLinksArea"
data-test-subj="links--panelEditor--linksAreaDroppable"
>
@ -322,3 +318,61 @@ const LinksEditor = ({
// required for dynamic import using React.lazy()
// eslint-disable-next-line import/no-default-export
export default LinksEditor;
const styles = {
droppableStyles: ({ euiTheme }: UseEuiTheme) => css({ margin: `0 -${euiTheme.size.xs}` }),
bodyStyles: css({
// EUI TODO: We need to set transform to 'none' to avoid drag/drop issues in the flyout caused by the
// `transform: translateZ(0)` workaround for the mask image bug in Chromium.
// https://github.com/elastic/eui/pull/7855.
'& .euiFlyoutBody__overflow': {
transform: 'none',
},
}),
flyoutStyles: ({ euiTheme }: UseEuiTheme) => {
const euiFlyoutOpenAnimation = keyframes`
0% {
opacity: 0;
transform: translateX(100%);
}
100% {
opacity: 1;
transform: translateX(0%);
}
`;
const euiFlyoutCloseAnimation = keyframes`
0% {
opacity: 1;
transform: translateX(0%);
}
100% {
opacity: 0;
transform: translateX(100%);
}`;
return css({
'.linkEditor': {
maxInlineSize: `calc(${euiTheme.size.xs} * 125)`,
height: 'calc(100vh - var(--euiFixedHeadersOffset, 0))',
position: 'fixed',
display: 'flex',
inlineSize: '50vw',
zIndex: euiTheme.levels.flyout,
alignItems: 'stretch',
flexDirection: 'column',
borderLeft: euiTheme.border.thin,
background: euiTheme.colors.backgroundBasePlain,
minWidth: `calc((${euiTheme.size.xl} * 13) + ${euiTheme.size.s})`, // 424px
'&.in': {
animation: `${euiFlyoutOpenAnimation} ${euiTheme.animation.normal} ${euiTheme.animation.resistance}`,
},
'&.out': {
animation: `${euiFlyoutCloseAnimation} ${euiTheme.animation.normal} ${euiTheme.animation.resistance}`,
},
},
});
},
};

View file

@ -7,7 +7,6 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import classNames from 'classnames';
import React, { useMemo } from 'react';
import {
@ -19,7 +18,10 @@ import {
EuiFlexGroup,
EuiButtonIcon,
DraggableProvidedDragHandleProps,
UseEuiTheme,
transparentize,
} from '@elastic/eui';
import { css } from '@emotion/react';
import { LinkInfo } from './constants';
import { LinksStrings } from '../links_strings';
@ -53,11 +55,7 @@ export const LinksEditorSingleLink = ({
/>
</EuiFlexItem>
<EuiFlexItem
className={classNames('linksPanelEditorLinkText', {
'linksPanelEditorLinkText--noLabel': !link.label,
})}
>
<EuiFlexItem className={'linksPanelEditorLinkText'}>
<EuiText size="s" color={'default'} className="eui-textTruncate">
{link.label || link.title}
</EuiText>
@ -85,10 +83,11 @@ export const LinksEditorSingleLink = ({
return (
<EuiPanel
hasBorder
css={styles}
hasShadow={false}
color={link.error ? 'warning' : 'plain'}
className={`linksPanelEditorLink ${link.error ? 'linkError' : ''}`}
data-test-subj={`panelEditorLink''}`}
data-test-subj={`panelEditorLink`}
>
<EuiFlexGroup gutterSize="s" responsive={false} wrap={false} alignItems="center">
<EuiFlexItem grow={false}>
@ -135,3 +134,28 @@ export const LinksEditorSingleLink = ({
</EuiPanel>
);
};
const styles = ({ euiTheme }: UseEuiTheme) =>
css({
padding: `${euiTheme.size.xs} ${euiTheme.size.s}`,
color: euiTheme.colors.textParagraph,
'.linksPanelEditorLinkText': {
flex: 1,
minWidth: 0,
},
'&.linkError': {
border: `1px solid ${transparentize(euiTheme.colors.textWarning, 0.3)}`,
color: euiTheme.colors.textWarning,
},
'& .links_hoverActions': {
position: 'absolute',
right: euiTheme.size.l,
opacity: 0,
visibility: 'hidden',
transition: `visibility ${euiTheme.animation.normal}, opacity ${euiTheme.animation.normal}`,
},
'&:hover .links_hoverActions, &:focus-within .links_hoverActions ': {
opacity: 1,
visibility: 'visible',
},
});

View file

@ -1,58 +0,0 @@
.linksComponent {
.linksPanelLink {
max-width: fit-content; // added this so that the error tooltip shows up **right beside** the link label
&.dashboardLinkError {
&.dashboardLinkError--noLabel .euiListGroupItem__button {
font-style: italic;
}
.dashboardLinkIcon {
margin-right: $euiSizeS;
}
}
&.linkCurrent {
border-radius: 0;
.euiListGroupItem__text {
cursor: default;
color: $euiColorPrimary;
}
}
}
.verticalLayoutWrapper {
gap: $euiSizeXS;
.linksPanelLink {
&.linkCurrent {
&::before {
content: '';
position: absolute;
width: .5 * $euiSizeXS;
height: 75%;
background-color: $euiColorPrimary;
}
}
}
}
.horizontalLayoutWrapper {
height: 100%;
display: flex;
flex-wrap: nowrap;
align-items: center;
flex-direction: row;
.linksPanelLink {
&.linkCurrent {
padding: 0 $euiSizeS;
.euiListGroupItem__text {
box-shadow: $euiColorPrimary 0 (-.5 * $euiSizeXS) inset;
padding-inline: 0;
}
}
}
}
}

View file

@ -142,7 +142,6 @@ export async function openEditorFlyout({
ownFocus: true,
onClose: onCancel,
outsideClickCloses: false,
className: 'linksPanelEditor',
'data-test-subj': 'links--panelEditor--flyout',
}
);

View file

@ -11,6 +11,7 @@ import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import { embeddablePluginMock } from '@kbn/embeddable-plugin/public/mocks';
import { setStubKibanaServices } from '@kbn/presentation-panel-plugin/public/mocks';
import { EuiThemeProvider } from '@elastic/eui';
import { getLinksEmbeddableFactory } from './links_embeddable';
import { Link } from '../../common/content_management';
import { CONTENT_ID } from '../../common';
@ -139,8 +140,27 @@ jest.mock('../content_management', () => {
};
});
const renderEmbeddable = (
parent: LinksParentApi,
overrides?: {
onApiAvailable: (api: LinksApi) => void;
}
) => {
return render(
<EuiThemeProvider>
<ReactEmbeddableRenderer<LinksSerializedState, LinksRuntimeState, LinksApi>
type={CONTENT_ID}
onApiAvailable={jest.fn()}
getParentApi={jest.fn().mockReturnValue(parent)}
{...overrides}
/>
</EuiThemeProvider>
);
};
describe('getLinksEmbeddableFactory', () => {
const factory = getLinksEmbeddableFactory();
beforeAll(() => {
const embeddable = embeddablePluginMock.createSetupContract();
embeddable.registerReactEmbeddableFactory(CONTENT_ID, async () => {
@ -185,30 +205,17 @@ describe('getLinksEmbeddableFactory', () => {
});
test('component renders', async () => {
render(
<ReactEmbeddableRenderer<LinksSerializedState, LinksApi>
type={CONTENT_ID}
getParentApi={() => parent}
/>
);
renderEmbeddable(parent);
expect(await screen.findByTestId('links--component')).toBeInTheDocument();
});
test('api methods', async () => {
const onApiAvailable = jest.fn() as jest.MockedFunction<(api: LinksApi) => void>;
render(
<ReactEmbeddableRenderer<LinksSerializedState, LinksRuntimeState, LinksApi>
type={CONTENT_ID}
onApiAvailable={onApiAvailable}
getParentApi={() => parent}
/>
);
renderEmbeddable(parent, { onApiAvailable });
await waitFor(async () => {
const api = onApiAvailable.mock.calls[0][0];
expect(await api.serializeState()).toEqual({
expect(api.serializeState()).toEqual({
rawState: {
savedObjectId: '123',
title: 'my links',
@ -225,18 +232,11 @@ describe('getLinksEmbeddableFactory', () => {
test('unlink from library', async () => {
const onApiAvailable = jest.fn() as jest.MockedFunction<(api: LinksApi) => void>;
render(
<ReactEmbeddableRenderer<LinksSerializedState, LinksRuntimeState, LinksApi>
type={CONTENT_ID}
onApiAvailable={onApiAvailable}
getParentApi={() => parent}
/>
);
renderEmbeddable(parent, { onApiAvailable });
await waitFor(async () => {
const api = onApiAvailable.mock.calls[0][0];
expect(await api.getSerializedStateByValue()).toEqual({
expect(api.getSerializedStateByValue()).toEqual({
rawState: {
title: 'my links',
description: 'just a few links',
@ -291,30 +291,18 @@ describe('getLinksEmbeddableFactory', () => {
});
test('component renders', async () => {
render(
<ReactEmbeddableRenderer<LinksSerializedState, LinksApi>
type={CONTENT_ID}
getParentApi={() => parent}
/>
);
renderEmbeddable(parent);
expect(await screen.findByTestId('links--component')).toBeInTheDocument();
});
test('api methods', async () => {
const onApiAvailable = jest.fn() as jest.MockedFunction<(api: LinksApi) => void>;
render(
<ReactEmbeddableRenderer<LinksSerializedState, LinksRuntimeState, LinksApi>
type={CONTENT_ID}
onApiAvailable={onApiAvailable}
getParentApi={() => parent}
/>
);
renderEmbeddable(parent, { onApiAvailable });
await waitFor(async () => {
const api = onApiAvailable.mock.calls[0][0];
expect(await api.serializeState()).toEqual({
expect(api.serializeState()).toEqual({
rawState: {
title: 'my links',
description: 'just a few links',
@ -332,14 +320,7 @@ describe('getLinksEmbeddableFactory', () => {
});
test('save to library', async () => {
const onApiAvailable = jest.fn() as jest.MockedFunction<(api: LinksApi) => void>;
render(
<ReactEmbeddableRenderer<LinksSerializedState, LinksRuntimeState, LinksApi>
type={CONTENT_ID}
onApiAvailable={onApiAvailable}
getParentApi={() => parent}
/>
);
renderEmbeddable(parent, { onApiAvailable });
await waitFor(async () => {
const api = onApiAvailable.mock.calls[0][0];
@ -353,7 +334,7 @@ describe('getLinksEmbeddableFactory', () => {
options: { references },
});
expect(newId).toBe('333');
expect(await api.getSerializedStateByReference(newId)).toEqual({
expect(api.getSerializedStateByReference(newId)).toEqual({
rawState: {
savedObjectId: '333',
title: 'my links',

View file

@ -10,7 +10,7 @@
import React, { createContext, useMemo } from 'react';
import { cloneDeep } from 'lodash';
import { BehaviorSubject } from 'rxjs';
import { EuiListGroup, EuiPanel } from '@elastic/eui';
import { EuiListGroup, EuiPanel, UseEuiTheme } from '@elastic/eui';
import { PanelIncompatibleError, ReactEmbeddableFactory } from '@kbn/embeddable-plugin/public';
import {
@ -19,6 +19,7 @@ import {
SerializedPanelState,
useBatchedOptionalPublishingSubjects,
} from '@kbn/presentation-publishing';
import { css } from '@emotion/react';
import {
CONTENT_ID,
@ -41,7 +42,6 @@ import {
import { DISPLAY_NAME } from '../../common';
import { injectReferences } from '../../common/persistable_state';
import '../components/links_component.scss';
import { checkForDuplicateTitle, linksClient } from '../content_management';
import { resolveLinks } from '../lib/resolve_links';
import {
@ -248,9 +248,7 @@ export const getLinksEmbeddableFactory = () => {
}, [links, layout]);
return (
<EuiPanel
className={`linksComponent ${
layout === LINKS_HORIZONTAL_LAYOUT ? 'eui-xScroll' : 'eui-yScroll'
}`}
className={layout === LINKS_HORIZONTAL_LAYOUT ? 'eui-xScroll' : 'eui-yScroll'}
paddingSize="xs"
data-shared-item
data-rendering-count={1}
@ -259,6 +257,7 @@ export const getLinksEmbeddableFactory = () => {
>
<EuiListGroup
maxWidth={false}
css={styles}
className={`${layout ?? LINKS_VERTICAL_LAYOUT}LayoutWrapper`}
data-test-subj="links--component--listGroup"
>
@ -275,3 +274,20 @@ export const getLinksEmbeddableFactory = () => {
};
return linksEmbeddableFactory;
};
const styles = ({ euiTheme }: UseEuiTheme) =>
css({
'.linksPanelLink': {
maxWidth: 'fit-content', // ensures that the error tooltip shows up **right beside** the link label
},
'&.verticalLayoutWrapper': {
gap: euiTheme.size.xs,
},
'&.horizontalLayoutWrapper': {
height: '100%',
display: 'flex',
flexWrap: 'nowrap',
alignItems: 'center',
flexDirection: 'row',
},
});

View file

@ -3,7 +3,14 @@
"compilerOptions": {
"outDir": "target/types"
},
"include": ["*.ts", "public/**/*", "common/**/*", "server/**/*", "public/**/*.json"],
"include": [
"*.ts",
"public/**/*",
"common/**/*",
"server/**/*",
"public/**/*.json",
"../../../../../typings/emotion.d.ts"
],
"kbn_references": [
"@kbn/core",
"@kbn/i18n",
@ -35,7 +42,7 @@
"@kbn/presentation-panel-plugin",
"@kbn/embeddable-enhanced-plugin",
"@kbn/share-plugin",
"@kbn/es-query",
"@kbn/es-query"
],
"exclude": ["target/**/*"]
}