mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
[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:
parent
d28227ca6a
commit
26e61c1413
4 changed files with 265 additions and 67 deletions
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue