Skip to content

Commit

Permalink
feat: sidebar for open-api-docs (#2099)
Browse files Browse the repository at this point in the history
* chore: enable flags for dev and review

* feat: stub in final sidebar and menu contents

* fix: mobile menu detect hash changes for anchors

* chore: extract extract-hash-slug

* chore: remove console.log

* refactor: rm extractHashSlug

* refactor: generate nav items in getStaticProps

* chore: rename type

* docs: add comment on MenuItem issue

* style: shift code for clarity

* fix: icon issue in sidebar highlight item

* feat: generate top-level page item

* chore: split out newUrl fn to src/lib

* docs: add comment to new-url

* chore: revert flags for dev and review
  • Loading branch information
zchsh authored Jul 31, 2023
1 parent 8169637 commit 9c71ee1
Show file tree
Hide file tree
Showing 13 changed files with 216 additions and 36 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,9 @@ export default function SidebarNavHighlightItem({
className={classNames(s.root, s[`theme-${theme}`])}
href={href}
>
{isProductSlug(theme) ? <ProductIcon productSlug={theme} /> : null}
{isProductSlug(theme) ? (
<ProductIcon className={s.icon} productSlug={theme} />
) : null}
<span className={s.text}>{text}</span>
</Link>
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@
font-weight: var(--token-typography-font-weight-medium);
}

.icon {
flex-shrink: 0;
}

/*
* Theming
*/
Expand Down
2 changes: 2 additions & 0 deletions src/contexts/mobile-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -112,10 +112,12 @@ const MobileMenuProvider = ({ children }: MobileMenuProviderProps) => {

router.events.on('routeChangeComplete', handleRouteChange)
router.events.on('routeChangeError', handleRouteChange)
router.events.on('hashChangeComplete', handleRouteChange)

return () => {
router.events.off('routeChangeComplete', handleRouteChange)
router.events.off('routeChangeError', handleRouteChange)
router.events.off('hashChangeComplete', handleRouteChange)
}
}, [isMobileMenuRendered, mobileMenuIsOpen, router.events])

Expand Down
22 changes: 22 additions & 0 deletions src/lib/new-url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/**
* Given a string, and an _optional_ domain name,
* Return a new URL object built from the string with `new URL()`.
*
* This function is intended as a light wrapper around the URL
* constructor, with the sole purpose of not requiring a domain
* name, and instead supply a default domain name,
* since there are several uses through our codebase where we want
* the functionality and reliability of URL objects, but do not
* have a meaningful domain name to supply.
*
* Note that we use `example.com` here since we do _not_ want to rely
* on a specific domain name value here. If a specific domain name
* is needed, consumers should pass that domain to the `base` argument,
* or use `new URL(url, base)` directly.
*/
export default function newUrl(
url: string | URL,
base: string | URL = 'https://www.example.com'
): URL {
return new URL(url, base)
}
1 change: 1 addition & 0 deletions src/pages/hcp/api-docs/vault-secrets/[[...page]].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ export const getStaticProps: GetStaticProps<
*/
if (USE_REVISED_TEMPLATE) {
return await getOpenApiDocsStaticProps({
basePath: BASE_URL,
context: { params },
productSlug: PRODUCT_SLUG,
// Handle rename of `targetFile` to `sourceFile` for new template
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,22 @@ import {
mobileMenuLevelMain,
mobileMenuLevelProduct,
} from 'components/mobile-menu-levels/level-components'
// Local
import { OpenApiSidebarContents } from '../open-api-sidebar-contents'
// Types
import type { ProductData } from 'types/products'
import type { OpenApiNavItem } from 'views/open-api-docs-view/types'

/**
* Placeholder for OpenApiDocsView mobile menu levels.
*/
export function OpenApiDocsMobileMenuLevels({
productData,
navItems,
}: {
// Product data, used to generate mobile menu levels.
productData: ProductData
navItems: OpenApiNavItem[]
}) {
return (
<MobileMenuLevels
Expand All @@ -33,9 +38,7 @@ export function OpenApiDocsMobileMenuLevels({
content: (
<div>
{/* API docs mobile menu contents */}
<div style={{ border: '1px solid magenta' }}>
PLACEHOLDER for OpenApiDocsView mobile menu contents
</div>
<OpenApiSidebarContents navItems={navItems} />
{/* Common resources for this product */}
<SidebarHorizontalRule />
<ProductResourceNavItems slug={productData.slug} />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,42 +3,84 @@
* SPDX-License-Identifier: MPL-2.0
*/

import { OperationGroup, OperationProps } from 'views/open-api-docs-view/types'
import { useMemo } from 'react'
import { useRouter } from 'next/router'
// Lib
import { useActiveSection } from 'lib/hash-links/use-active-section'
import newUrl from 'lib/new-url'
// Components
import { SidebarNavMenuItem } from 'components/sidebar/components'
// Types
import type { OpenApiNavItem } from 'views/open-api-docs-view/types'
// Styles
import s from './open-api-sidebar-contents.module.css'

/**
* Renders sidebar contents for OpenApiDocsView.
*
* TODO: implement this component.
* Designs show a list of navigational items
* that link to the corresponding #operation-slug on the page.
* We also want to highlight the current navigational item.
*
* Note: we likely also want to re-use this component to render
* the OpenApiDocsView level of the corresponding mobile nav.
*/
export function OpenApiSidebarContents({
operationGroups,
navItems,
}: {
operationGroups: OperationGroup[]
navItems: OpenApiNavItem[]
}) {
const { asPath } = useRouter()

/**
* Note next/router `asPath` returns *with* the `#hash`.
*
* Here, we remove the hash, so that we can compare to the `fullPath`
* of each nav item, which are not expected to have hashes,
* to the URL path that we're on.
*
* Note that domain does not matter, as we're just grabbing the full path.
*/
const urlPathname = useMemo(() => newUrl(asPath).pathname, [asPath])

/**
* Build an array of section slugs,
* and pass these to `useActiveSection`.
*/
const sectionSlugs = useMemo(() => {
return (
navItems
.map((item) => {
// Only grab slugs from link items
if (!('fullPath' in item)) {
return null
}
// Grab the `#hash-slug` without `#`
return newUrl(item.fullPath).hash.replace('#', '')
})
// Filter out any null or empty string values
.filter((slug) => typeof slug === 'string' && slug !== '')
)
}, [navItems])
const activeSection = useActiveSection(sectionSlugs)

/**
* Highlight any active matched sidenav items.
* These could match `#activeSection` or the full pathname.
*/
const navItemsWithActive = useMemo(() => {
return navItems.map((item) => {
// Handle dividers, headings, any non-path items
if (!('fullPath' in item)) {
return item
}
// Handle items with paths, that could be a match
const isActiveHash = item.fullPath === `#${activeSection}`
const isActivePath = item.fullPath === urlPathname
return { ...item, isActive: isActivePath || isActiveHash }
})
}, [navItems, activeSection, urlPathname])

// Render a generic list of `SideBarNavMenuItem`
return (
<div style={{ border: '1px solid magenta', overflow: 'hidden' }}>
{operationGroups.map((group) => {
return (
<>
<div>{group.heading}</div>
<ul key={group.heading}>
{group.items.map((o: OperationProps) => {
return (
<li key={o.operationId}>
<a href={`#${o.slug}`}>{o.operationId}</a>
</li>
)
})}
</ul>
</>
)
})}
</div>
<ul className={s.listResetStyles}>
{navItemsWithActive.map((item: OpenApiNavItem, index: number) => (
// eslint-disable-next-line react/no-array-index-key
<SidebarNavMenuItem item={item} key={index} />
))}
</ul>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
.listResetStyles {
list-style: none;
margin: 0;
padding: 0;
width: 100%;
}
17 changes: 15 additions & 2 deletions src/views/open-api-docs-view/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@

// Layout
import SidebarLayout from 'layouts/sidebar-layout'
// Components
import SidebarBackToLink from 'components/sidebar/components/sidebar-back-to-link'
// Local
import {
OpenApiDocsMobileMenuLevels,
Expand All @@ -21,12 +23,23 @@ import type { OpenApiDocsViewProps } from './types'
function OpenApiDocsView({
productData,
operationGroups,
navItems,
_placeholder,
}: OpenApiDocsViewProps) {
return (
<SidebarLayout
sidebarSlot={<OpenApiSidebarContents operationGroups={operationGroups} />}
mobileMenuSlot={<OpenApiDocsMobileMenuLevels productData={productData} />}
sidebarSlot={
<>
<SidebarBackToLink text="HashiCorp Cloud Platform" href="/hcp" />
<OpenApiSidebarContents navItems={navItems} />
</>
}
mobileMenuSlot={
<OpenApiDocsMobileMenuLevels
productData={productData}
navItems={navItems}
/>
}
>
<OpenApiOverview _placeholder={_placeholder} />
<OpenApiOperations operationGroups={operationGroups} />
Expand Down
11 changes: 11 additions & 0 deletions src/views/open-api-docs-view/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { cachedGetProductData } from 'lib/get-product-data'
// Utilities
import {
findLatestStableVersion,
getNavItems,
getOperationProps,
groupOperations,
parseAndValidateOpenApiSchema,
Expand Down Expand Up @@ -61,10 +62,12 @@ export async function getStaticProps({
context,
productSlug,
versionData,
basePath,
}: {
context: GetStaticPropsContext<OpenApiDocsParams>
productSlug: ProductSlug
versionData: OpenApiDocsVersionData[]
basePath: string
}): Promise<GetStaticPropsResult<OpenApiDocsViewProps>> {
// Get the product data
const productData = cachedGetProductData(productSlug)
Expand Down Expand Up @@ -97,8 +100,15 @@ export async function getStaticProps({
? sourceFile
: await fetchGithubFile(sourceFile)
const schemaData = await parseAndValidateOpenApiSchema(schemaFileString)
const { title } = schemaData.info
const operationProps = getOperationProps(schemaData)
const operationGroups = groupOperations(operationProps)
const navItems = getNavItems({
operationGroups,
basePath,
title,
productSlug: productData.slug,
})

/**
* Return props
Expand All @@ -113,6 +123,7 @@ export async function getStaticProps({
schemaData,
},
operationGroups: stripUndefinedProperties(operationGroups),
navItems,
},
}
}
27 changes: 26 additions & 1 deletion src/views/open-api-docs-view/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
*/

import type { ParsedUrlQuery } from 'querystring'
import type { ProductData } from 'types/products'
import type { ProductData, ProductSlug } from 'types/products'
import type { GithubFile } from 'lib/fetch-github-file'

/**
Expand Down Expand Up @@ -77,6 +77,27 @@ export interface OpenApiDocsParams extends ParsedUrlQuery {
page: string[]
}

/**
* Nav items are used to render the sidebar and mobile nav.
*
* TODO: move these types to sidebar component.
* For now, this is difficult, as the MenuItem type is a complex
* interface that requires a larger effort to untangle, and all
* related Sidebar components are similarly entangled.
* Rationale is to start with simpler slightly duplicative types here,
* rather than try to embark on the `MenuItem` type refactor.
* Task: https://app.asana.com/0/1202097197789424/1202405210286689/f
*/
type DividerNavItem = { divider: true }
type HeadingNavItem = { heading: string }
type LinkNavItem = {
title: string
fullPath: string
theme?: ProductSlug
}

export type OpenApiNavItem = DividerNavItem | HeadingNavItem | LinkNavItem

/**
* We'll use this type to document the shape of props for the view component.
* For now, we have a placeholder. We'll expand this as we build out the view.
Expand All @@ -89,6 +110,10 @@ export interface OpenApiDocsViewProps {
* They're grouped into sections based on operation paths.
*/
operationGroups: OperationGroup[]
/**
* Operation nav items are rendered into the sidebar and mobile nav.
*/
navItems: OpenApiNavItem[]
/**
* Some temporary data we'll remove for the production implementation.
*/
Expand Down
48 changes: 48 additions & 0 deletions src/views/open-api-docs-view/utils/get-nav-items.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { ProductSlug } from 'types/products'
import { OperationGroup, OpenApiNavItem } from '../types'

/**
* Build nav items for each operation, group-by-group.
*/
export function getNavItems({
basePath,
operationGroups,
productSlug,
title,
}: {
basePath: string
operationGroups: OperationGroup[]
productSlug: ProductSlug
title: string
}): OpenApiNavItem[] {
// Build the top-level page nav item
const pageNavItem = {
title,
fullPath: basePath,
theme: productSlug,
}
// Include grouped operation items
const operationGroupItems = getOperationGroupItems(operationGroups)
// Return the full set of nav items
return [pageNavItem, ...operationGroupItems]
}

/**
* Build nav items for each operation, group-by-group.
*/
function getOperationGroupItems(
operationGroups: OperationGroup[]
): OpenApiNavItem[] {
const navItems: OpenApiNavItem[] = []
// Build items for each group
for (const { heading, items } of Object.values(operationGroups)) {
// Start each group with a divider and heading
navItems.push({ divider: true })
navItems.push({ heading })
// Then include each group's items
for (const { slug, operationId } of items) {
navItems.push({ title: operationId, fullPath: `#${slug}` })
}
}
return navItems
}
Loading

1 comment on commit 9c71ee1

@vercel
Copy link

@vercel vercel bot commented on 9c71ee1 Jul 31, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.