[Fleet] Add sidebarNav showing headings extracted from the readme (#167216)

## Summary

Add a dynamic side bar navigation to Integrations overview.
The
[sideNavBar](https://eui.elastic.co/#/navigation/side-nav#complex-side-nav)
is automatically generated starting from the headers contained in the
markdown. I had two challenges here:
- Extracting a nested structure formatted the way [the sidenav
requires](https://eui.elastic.co/#/navigation/side-nav#complex-side-nav)
from the headers
- Connecting the elements in the sidenav with the ones in the readme
page. The readme page is directly rendered via
[react-markdown](https://github.com/remarkjs/react-markdown) library and
we introduce some modifiers to it.
To be able to scroll the page to the selected item I had to find a way
to have unique Ids (I used the line position of the headers for this)
and the I had to get the refs of the html elements and pass them to the
sidenav.


7a985a98-fdb5-4e2d-bb4f-ba71f46cf86f




### Screenshots
<img width="2052" alt="Screenshot 2023-09-28 at 11 58 55"
src="6aa181ee-2d45-4696-bc3d-273ffc7fad2f">
<img width="1681" alt="Screenshot 2023-09-26 at 17 06 04"
src="d460b136-8b8d-4649-8fc5-1f39b60a394f">


### Checklist
- [ ]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials
- [ ] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [ ] This renders correctly on smaller devices using a responsive
layout. (You can test this [in your
browser](https://www.browserstack.com/guide/responsive-testing-on-local-server))

---------

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Cristina Amico 2023-10-02 19:35:22 +02:00 committed by GitHub
parent d28227ca6a
commit 26e61c1413
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 265 additions and 67 deletions

View file

@ -293,7 +293,7 @@ export function Detail() {
const headerLeftContent = useMemo(
() => (
<EuiFlexGroup direction="column" gutterSize="m">
<EuiFlexGroup direction="column" gutterSize="m" data-test-subj="headerLeft">
<EuiFlexItem>
{/* Allows button to break out of full width */}
<div>

View file

@ -10,11 +10,16 @@ import {
EuiCodeBlock,
EuiLink,
EuiTableHeaderCell,
EuiTable,
EuiTableRow,
EuiTableRowCell,
} from '@elastic/eui';
import React from 'react';
import type { MutableRefObject } from 'react';
import type { TransformOptions } from 'react-markdown';
import styled from 'styled-components';
import { getAnchorId } from './overview';
/** prevents links to the new pages from accessing `window.opener` */
const REL_NOOPENER = 'noopener';
@ -31,42 +36,80 @@ const CODE_LANGUAGE_OVERRIDES: Record<string, string> = {
$yml: 'yml',
};
export const markdownRenderers: TransformOptions['components'] = {
table: ({ children }) => <table className="euiTable euiTable--responsive">{children}</table>,
tr: ({ children }) => <EuiTableRow>{children}</EuiTableRow>,
th: ({ children }) => <EuiTableHeaderCell>{children}</EuiTableHeaderCell>,
td: ({ children }) => <EuiTableRowCell>{children}</EuiTableRowCell>,
// the headings used in markdown don't match our page so mapping them to the appropriate one
h1: ({ children }) => <h3>{children}</h3>,
h2: ({ children }) => <h4>{children}</h4>,
h3: ({ children }) => <h5>{children}</h5>,
h4: ({ children }) => <h6>{children}</h6>,
h5: ({ children }) => <h6>{children}</h6>,
h6: ({ children }) => <h6>{children}</h6>,
link: ({ children, href }: { children: React.ReactNode[]; href?: string }) => (
<EuiLink href={href} target="_blank" rel={`${REL_NOOPENER} ${REL_NOFOLLOW} ${REL_NOREFERRER}`}>
{children}
</EuiLink>
),
code: ({ className, children, inline }) => {
let parsedLang = /language-(\w+)/.exec(className || '')?.[1] ?? '';
const StyledH3 = styled.h3`
scroll-margin-top: 105px;
`;
const StyledH4 = styled.h4`
scroll-margin-top: 105px;
`;
const StyledH5 = styled.h5`
scroll-margin-top: 105px;
`;
const StyledH6 = styled.h5`
scroll-margin-top: 105px;
`;
// Some integrations export code block content that includes language tags that have since
// been removed or deprecated in `prism.js`, the upstream depedency that handles syntax highlighting
// in EuiCodeBlock components
const languageOverride = parsedLang ? CODE_LANGUAGE_OVERRIDES[parsedLang] : undefined;
if (languageOverride) {
parsedLang = languageOverride;
}
if (inline) {
return <EuiCode>{children}</EuiCode>;
}
return (
<EuiCodeBlock language={parsedLang} isCopyable>
/*
Refs passed from the parent component are needed to handle scrolling on selected header in the overviewPage
*/
export const markdownRenderers = (
refs: MutableRefObject<Map<string, HTMLDivElement | null>>
): TransformOptions['components'] => {
return {
table: ({ children }) => (
<EuiTable className="euiEuiTable euiTable--responsive">{children}</EuiTable>
),
tr: ({ children }) => <EuiTableRow>{children}</EuiTableRow>,
th: ({ children }) => <EuiTableHeaderCell>{children}</EuiTableHeaderCell>,
td: ({ children }) => <EuiTableRowCell>{children}</EuiTableRowCell>,
// the headings used in markdown don't match our page so mapping them to the appropriate one
h1: ({ children, node }) => {
const id = getAnchorId(children[0]?.toString(), node.position?.start.line);
return <StyledH3 ref={(element) => refs.current.set(`${id}`, element)}>{children}</StyledH3>;
},
h2: ({ children, node }) => {
const id = getAnchorId(children[0]?.toString(), node.position?.start.line);
return <StyledH4 ref={(element) => refs.current.set(`${id}`, element)}>{children}</StyledH4>;
},
h3: ({ children, node }) => {
const id = getAnchorId(children[0]?.toString(), node.position?.start.line);
return <StyledH5 ref={(element) => refs.current.set(`${id}`, element)}>{children}</StyledH5>;
},
h4: ({ children, node }) => {
const id = getAnchorId(children[0]?.toString(), node.position?.start.line);
return <StyledH6 ref={(element) => refs.current.set(`${id}`, element)}>{children}</StyledH6>;
},
h5: ({ children }) => <h6>{children}</h6>,
h6: ({ children }) => <h6>{children}</h6>,
link: ({ children, href }: { children: React.ReactNode[]; href?: string }) => (
<EuiLink
href={href}
target="_blank"
rel={`${REL_NOOPENER} ${REL_NOFOLLOW} ${REL_NOREFERRER}`}
>
{children}
</EuiCodeBlock>
);
},
</EuiLink>
),
code: ({ className, children, inline }) => {
let parsedLang = /language-(\w+)/.exec(className || '')?.[1] ?? '';
// Some integrations export code block content that includes language tags that have since
// been removed or deprecated in `prism.js`, the upstream depedency that handles syntax highlighting
// in EuiCodeBlock components
const languageOverride = parsedLang ? CODE_LANGUAGE_OVERRIDES[parsedLang] : undefined;
if (languageOverride) {
parsedLang = languageOverride;
}
if (inline) {
return <EuiCode>{children}</EuiCode>;
}
return (
<EuiCodeBlock language={parsedLang} overflowHeight={500} isCopyable>
{children}
</EuiCodeBlock>
);
},
};
};

View file

@ -4,9 +4,19 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { memo, useMemo } from 'react';
import React, { memo, useMemo, useEffect, useState, useCallback, useRef } from 'react';
import type { MouseEventHandler } from 'react';
import styled from 'styled-components';
import { EuiCallOut, EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiLink, EuiButton } from '@elastic/eui';
import {
EuiCallOut,
EuiFlexGroup,
EuiFlexItem,
EuiSpacer,
EuiLink,
EuiButton,
EuiSideNav,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
@ -19,6 +29,7 @@ import {
useGetPackageVerificationKeyId,
useLink,
useStartServices,
sendGetFileByPath,
} from '../../../../../../../hooks';
import { isPackageUnverified } from '../../../../../../../services';
import type { PackageInfo, RegistryPolicyTemplate } from '../../../../../types';
@ -33,11 +44,31 @@ interface Props {
latestGAVersion?: string;
}
const LeftColumn = styled(EuiFlexItem)`
/* 🤢🤷 https://www.styled-components.com/docs/faqs#how-can-i-override-styles-with-higher-specificity */
&&& {
margin-top: 77px;
}
interface Item {
id: string;
name: string;
items?: Item[];
isSelected?: boolean;
forceOpen: boolean;
onClick: MouseEventHandler<HTMLElement | HTMLButtonElement>;
}
interface HeadingWithPosition {
line: string;
position: number;
}
const SideBar = styled(EuiFlexItem)`
position: sticky;
top: 70px;
padding-top: 50px;
padding-left: 10px;
text-overflow: ellipsis;
max-width: 180px;
max-height: 500px;
`;
const StyledSideNav = styled(EuiSideNav)`
overflow-y: auto;
overflow-x: hidden;
`;
const UnverifiedCallout: React.FC = () => {
@ -113,6 +144,13 @@ const PrereleaseCallout: React.FC<{
);
};
// some names are too long so they're trimmed at 12 characters long
export const getAnchorId = (name: string | undefined, index?: number) => {
if (!name) return '';
const baseId = `${name.replaceAll(' ', '-').toLowerCase().slice(0, 12)}`;
return index ? `${baseId}-${index}` : baseId;
};
export const OverviewPage: React.FC<Props> = memo(
({ packageInfo, integrationInfo, latestGAVersion }) => {
const screenshots = useMemo(
@ -122,9 +160,135 @@ export const OverviewPage: React.FC<Props> = memo(
const { packageVerificationKeyId } = useGetPackageVerificationKeyId();
const isUnverified = isPackageUnverified(packageInfo, packageVerificationKeyId);
const isPrerelease = isPackagePrerelease(packageInfo.version);
const [markdown, setMarkdown] = useState<string | undefined>(undefined);
const [selectedItemId, setSelectedItem] = useState<string | undefined>(undefined);
const [isSideNavOpenOnMobile, setIsSideNavOpenOnMobile] = useState(false);
const anchorsRefs = useRef(new Map<string, HTMLDivElement | null>());
const selectItem = (id: string) => {
setSelectedItem(id);
anchorsRefs.current.get(id)?.scrollIntoView({ behavior: 'smooth' });
};
const toggleOpenOnMobile = () => {
setIsSideNavOpenOnMobile(!isSideNavOpenOnMobile);
};
const readmePath =
integrationInfo && isIntegrationPolicyTemplate(integrationInfo) && integrationInfo?.readme
? integrationInfo?.readme
: packageInfo.readme || '';
useEffect(() => {
sendGetFileByPath(readmePath).then((res) => {
setMarkdown(res.data || '');
});
}, [readmePath]);
const extractHeadingsWithIndices = (markDown: string | undefined): HeadingWithPosition[] => {
if (!markDown) return [];
const regex = /^\s*#+\s+(.+)/;
return markDown
.split('\n')
.map((line, position) => {
return {
line,
position,
};
})
.filter((obj) => obj.line.match(regex));
};
const getName = (heading: string) => heading.replace(/^#+\s*/, '');
const createItem = useCallback(
(heading: HeadingWithPosition, options: any = {}): Item => {
// NOTE: Duplicate `name` values will cause `id` collisions
const name = getName(heading.line);
const id = getAnchorId(name, heading.position + 1);
return {
id,
name,
isSelected: selectedItemId === id,
onClick: () => selectItem(id),
...options,
};
},
[selectedItemId]
);
// get the headings and creates a nested structure as requested by EuiSideNav
const headingsToNavItems = useCallback(
(headings: HeadingWithPosition[]): Item[] => {
const options = { forceOpen: true };
return headings.reduce((acc: Item[], heading: HeadingWithPosition, index: number) => {
if (heading.line.startsWith('## ')) {
const item = createItem(heading, options);
acc.push(item);
} else if (heading.line.startsWith('### ')) {
const subGroup = createItem(heading, options);
let i = index + 1;
while (i < headings.length && headings[i].line.startsWith('#### ')) {
const subGroupItem = createItem(headings[i], options);
if (!subGroup?.items) subGroup.items = [];
subGroup.items?.push(subGroupItem);
i++;
}
const prevIndex = acc.length - 1;
if (prevIndex >= 0) {
if (!acc[prevIndex]?.items) acc[prevIndex].items = [];
acc[prevIndex]?.items?.push(subGroup);
} else {
// this handles a case where the headings only have ### and no ##
const fakeItem = createItem({ line: '', position: heading.position }, options);
acc.push(fakeItem);
if (!acc[0]?.items) acc[0].items = [];
acc[0]?.items?.push(subGroup);
}
}
return acc;
}, []);
},
[createItem]
);
const headingsWithIndices = useMemo(() => extractHeadingsWithIndices(markdown), [markdown]);
const navItems = useMemo(
() => headingsToNavItems(headingsWithIndices),
[headingsToNavItems, headingsWithIndices]
);
const h1: HeadingWithPosition | undefined = useMemo(
() => headingsWithIndices.find((h) => h.line.startsWith('# ')),
[headingsWithIndices]
);
const sideNavItems = useMemo(() => {
const name = `${h1 ? getName(h1.line) : ''}`;
const id = getAnchorId(name, h1 ? h1?.position + 1 : 1);
return [
{
name,
id,
onClick: () => selectItem(id),
items: navItems,
},
];
}, [h1, navItems]);
return (
<EuiFlexGroup alignItems="flexStart">
<LeftColumn grow={2} />
<EuiFlexGroup alignItems="flexStart" data-test-subj="epm.OverviewPage">
<SideBar grow={2}>
{sideNavItems ? (
<StyledSideNav
mobileTitle="Nav Items"
toggleOpenOnMobile={toggleOpenOnMobile}
isOpenOnMobile={isSideNavOpenOnMobile}
items={sideNavItems}
/>
) : null}
</SideBar>
<EuiFlexItem grow={9} className="eui-textBreakWord">
{isUnverified && <UnverifiedCallout />}
{isPrerelease && (
@ -136,15 +300,10 @@ export const OverviewPage: React.FC<Props> = memo(
)}
{packageInfo.readme ? (
<Readme
readmePath={
integrationInfo &&
isIntegrationPolicyTemplate(integrationInfo) &&
integrationInfo?.readme
? integrationInfo?.readme
: packageInfo.readme
}
markdown={markdown}
packageName={packageInfo.name}
version={packageInfo.version}
refs={anchorsRefs}
/>
) : null}
</EuiFlexItem>

View file

@ -6,24 +6,26 @@
*/
import { EuiText, EuiSkeletonText, EuiSpacer } from '@elastic/eui';
import React, { Fragment, useEffect, useState } from 'react';
import React from 'react';
import type { MutableRefObject } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { useLinks, sendGetFileByPath } from '../../../../../hooks';
import { useLinks } from '../../../../../hooks';
import { markdownRenderers } from './markdown_renderers';
export function Readme({
readmePath,
packageName,
version,
markdown,
refs,
}: {
readmePath: string;
packageName: string;
version: string;
markdown: string | undefined;
refs: MutableRefObject<Map<string, HTMLDivElement | null>>;
}) {
const [markdown, setMarkdown] = useState<string | undefined>(undefined);
const { toRelativeImage } = useLinks();
const handleImageUri = React.useCallback(
(uri: string) => {
@ -35,19 +37,13 @@ export function Readme({
[toRelativeImage, packageName, version]
);
useEffect(() => {
sendGetFileByPath(readmePath).then((res) => {
setMarkdown(res.data || '');
});
}, [readmePath]);
return (
<Fragment>
<>
{markdown !== undefined ? (
<EuiText grow={true}>
<ReactMarkdown
transformImageUri={handleImageUri}
components={markdownRenderers}
components={markdownRenderers(refs)}
remarkPlugins={[remarkGfm]}
>
{markdown}
@ -63,6 +59,6 @@ export function Readme({
<EuiSkeletonText lines={4} />
</EuiText>
)}
</Fragment>
</>
);
}