Skip to content

Commit

Permalink
OpenAPI V2 - implement sidebar (#2594)
Browse files Browse the repository at this point in the history
* fix IS_VERCEL_DEPLOY check logic

Previously if VERCEL_ENV was equal to anything other than `development`, then we'd have `IS_VERCEL_DEPLOY` as `true`. This isn't accurate in cases where `VERCEL_ENV` is undefined, which is sometimes the case when running the site locally.

* add note to preview landing on reload issue

* add open api sidebar related utils

* add schema transforms

* add disentangled sidebar link components

* use new sidebar components, update types

* remove now-unused utility

* enable new features in preview tool

* fix bad property access in test

* avoid conditionals within smaller components

* remove placeholder

* rm dupe fn

* Fix typo in comment on sidebar-link

Co-authored-by: Noel Quiles <[email protected]>

* Fix missing wording in preview fallback page

---------

Co-authored-by: Noel Quiles <[email protected]>
  • Loading branch information
zchsh and EnMod authored Oct 31, 2024
1 parent 06244f9 commit 7ea293b
Show file tree
Hide file tree
Showing 26 changed files with 931 additions and 133 deletions.
3 changes: 1 addition & 2 deletions src/pages/api/open-api-docs-preview-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,12 @@ import path from 'path'
import { randomUUID } from 'crypto'
// Types
import type { NextApiRequest, NextApiResponse } from 'next'
import { OpenApiPreviewV2InputValues } from 'views/open-api-docs-preview-v2/components/open-api-preview-inputs'

/**
* Setup for temporary file storage
*/
// Determine if we're deploying to Vercel, this affects the temporary directory
const IS_VERCEL_DEPLOY = process.env.VERCEL_ENV !== 'development'
const IS_VERCEL_DEPLOY = typeof process.env.VERCEL_ENV === 'string'
// Determine the temporary directory to use, based on Vercel deploy or not
const TMP_DIR = IS_VERCEL_DEPLOY ? '/tmp' : path.join(process.cwd(), '.tmp')
// Ensure the temporary directory exists, so we can stash files
Expand Down
5 changes: 4 additions & 1 deletion src/views/open-api-docs-preview-v2/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,10 @@ function OpenApiDocsPreviewViewV2({
<div style={{ padding: '24px', maxWidth: '35em' }}>
<h1>OpenAPI Preview Tool</h1>
<p>
{`Please use the input form on this page to upload your spec. After submitting the form, the page should reload and display a preview.`}
{`Please use the input form on this page to upload your spec. After submitting the form, the page should reload and display a preview. For security reasons, after around an hour, your submitted file will be deleted the next time this page is loaded. You will need to re-submit your spec if you're working with this tool for longer than an hour.`}
</p>
<p>
{`This page may appear unexpectedly after navigation rather the expected preview content due to differences in how this preview tool works relative to production contexts. Reloading the page may resolve the issue. If reloading the page doesn't do anything, you may need to re-submit your spec file. If you're experiencing frequent issues requiring page reloads, reach out in #team-web-support in Slack.`}
</p>
</div>
</SidebarLayout>
Expand Down
2 changes: 1 addition & 1 deletion src/views/open-api-docs-preview-v2/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ const IS_PRODUCTION = process.env.VERCEL_ENV === 'production'
* where NextJS decides to start on a different port (eg if 3000 is in use),
* but I couldn't find a way to detect that, and for now, this seems sufficient.
*/
const IS_VERCEL_DEPLOY = process.env.VERCEL_ENV !== 'development'
const IS_VERCEL_DEPLOY = typeof process.env.VERCEL_ENV === 'string'
const BASE_URL = IS_VERCEL_DEPLOY
? `https://${process.env.VERCEL_URL}`
: 'http://localhost:3000'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,14 @@
*/

import { getStaticProps } from 'views/open-api-docs-view-v2/server'
// Utils
import { getOperationGroupKeyFromPath } from 'views/open-api-docs-view-v2/utils/get-operation-group-key-from-path'
import { schemaTransformShortenHcp } from 'views/open-api-docs-view-v2/schema-transforms/schema-transform-shorten-hcp'
// Types
import type { OpenApiDocsViewV2Props } from 'views/open-api-docs-view-v2/types'
import type {
OpenApiDocsViewV2Props,
OpenApiDocsViewV2Config,
} from 'views/open-api-docs-view-v2/types'
import type { OpenApiPreviewV2InputValues } from '../components/open-api-preview-inputs'

/**
Expand All @@ -28,10 +34,44 @@ export default async function getPropsFromPreviewData(
if (!previewData) {
return null
}
// Use the incoming preview data to generate static props for the view
return await getStaticProps({
// Set up transformers for the spec. Typically we want to avoid these,
// and prefer to have content updates made at the content source... but
// some shims are used often enough that they feel worth including in the
// preview too. Namely, shortening to `HCP` in the spec title.
const schemaTransforms = [schemaTransformShortenHcp]
// Build page configuration based on the input values
const pageConfig: OpenApiDocsViewV2Config = {
basePath: '/open-api-docs-preview-v2',
operationSlug,
openApiJsonString: previewData.openApiJsonString,
})
schemaTransforms,
// A generic set of resource links, as a preview of what typically
// gets added to an OpenAPI docs page.
resourceLinks: [
{
text: 'Tutorial Library',
href: '/tutorials/library',
},
{
text: 'Certifications',
href: '/certifications',
},
{
text: 'Community',
href: 'https://discuss.hashicorp.com/',
},
{
text: 'Support',
href: 'https://www.hashicorp.com/customer-success',
},
],
}
// If the user has requested to group operations by path, we'll do so
// by providing a custom `getOperationGroupKey` function. If this is omitted,
// we go with the default behaviour of grouping operations based on `tag`.
if (previewData.groupOperationsByPath) {
pageConfig.getOperationGroupKey = getOperationGroupKeyFromPath
}
// Use the page config to generate static props for the view
return await getStaticProps(pageConfig)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/

// Components
import { IconExternalLink16 } from '@hashicorp/flight-icons/svg-react/external-link-16'
import { SidebarLink, SidebarLinkText } from '../sidebar-link'
// Types
import type { PropsWithChildren } from 'react'
// Styles
import s from './style.module.css'

/**
* Render a SidebarLink with an external link icon.
*/
export function SidebarLinkExternal({
href,
children,
}: PropsWithChildren<{
href: string
}>) {
return (
<SidebarLink
className={s.root}
href={href}
/**
* We've decided to open external links in new tabs, note there's
* underlying logic in @components/link that handles target="_blank"
*/
target="_blank"
/**
* Modern browsers treat target="_blank" as implicitly
* having noopener, but we include it explicitly anyways.
* Note that in past implementations of similar components, we added
* rel=noreferrer as well. This doesn't seem necessary in a sidebar
* context, where we control which links appear, and will likely benefit
* from knowing when users are referred from our docs to pages elsewhere
* within our web presence.
*/
rel="noopener"
>
<SidebarLinkText>{children}</SidebarLinkText>
<span className={s.icon}>
<IconExternalLink16 />
</span>
</SidebarLink>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
.root {
display: flex;
gap: 8px;
justify-content: space-between;
align-items: center;
}

.icon {
/* flex-shrink: 0 prevents the icon from shrinking */
flex-shrink: 0;

/* display: block removes built-in margin that'd otherwise appear */
& > svg {
display: block;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/

// Components
import ProductIcon from 'components/product-icon'
import Text from 'components/text'
import { SidebarLink } from '../sidebar-link'
// Utils
import { isProductSlug } from 'lib/products'
// Types
import type { ProductSlug } from 'types/products'
// Styles
import s from './style.module.css'

/**
* Render a fancy-looking, product themed linked sidebar item.
*
* Intended to be used as a kind of title-ish element, to establish hierarchy
* within sidebar contents.
*
* This component is largely the same as SidebarNavHighlightItem, but
* requires `href`, and eliminates some related conditional rendering.
* See notes in `../sidebar-link` for more thoughts on a potential move
* away from previously implemented sidebar link components.
*/
export function SidebarLinkWithProductIcon({
productSlug,
text,
href,
isActive,
}: {
productSlug: ProductSlug
text: string
href: string
isActive: boolean
}) {
const icon = isProductSlug(productSlug) ? (
<ProductIcon className={s.icon} productSlug={productSlug} />
) : null

return (
<SidebarLink
aria-current={isActive ? 'page' : undefined}
className={s.root}
href={href}
>
{icon}
<Text asElement="span" size={200} weight="medium">
{text}
</Text>
</SidebarLink>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
.root {
/* Generic default theme, eg. for HCP and Sentinel */
--gradient-start: var(--token-color-palette-neutral-100);
--gradient-stop: var(--token-color-palette-neutral-50);
--inset-border-color: var(--token-color-palette-neutral-200);

/* Set up themed property helpers */
--inset-border-shadow: inset 0 0 0 1px var(--inset-border-color);
--gradient-background: linear-gradient(
315deg,
var(--gradient-start) 0%,
var(--gradient-stop) 100%
);

/* Layout icon and text */
display: flex;
align-items: center;
gap: 8px;

/* Builds on SidebarLink hover style, adds gradient background */
&:hover {
background: var(--gradient-background);
}

/* Builds on SidebarLink current page style, adds gradient background,
and box-shadow border when not focused. */
&[aria-current='page'] {
background: var(--gradient-background);

&:not(:focus-visible) {
box-shadow: var(--inset-border-shadow);
}
}
}

.icon {
flex-shrink: 0;
}

/* Theming by product slug */

.theme-terraform {
--gradient-start: var(--token-color-terraform-gradient-faint-stop);
--gradient-stop: var(--token-color-terraform-gradient-faint-start);
--inset-border-color: var(--token-color-terraform-border);
}

.theme-packer {
--gradient-start: var(--token-color-packer-gradient-faint-stop);
--gradient-stop: var(--token-color-packer-gradient-faint-start);
--inset-border-color: var(--token-color-packer-border);
}

.theme-consul {
--gradient-start: var(--token-color-consul-gradient-faint-stop);
--gradient-stop: var(--token-color-consul-gradient-faint-start);
--inset-border-color: var(--token-color-consul-border);
}

.theme-vault {
--gradient-start: var(--token-color-vault-gradient-faint-stop);
--gradient-stop: var(--token-color-vault-gradient-faint-start);
--inset-border-color: var(--token-color-vault-border);
}

.theme-boundary {
--gradient-start: var(--token-color-boundary-gradient-faint-stop);
--gradient-stop: var(--token-color-boundary-gradient-faint-start);
--inset-border-color: var(--token-color-boundary-border);
}

.theme-nomad {
--gradient-start: var(--token-color-nomad-gradient-faint-stop);
--gradient-stop: var(--token-color-nomad-gradient-faint-start);
--inset-border-color: var(--token-color-nomad-border);
}

.theme-waypoint {
--gradient-start: var(--token-color-waypoint-gradient-faint-stop);
--gradient-stop: var(--token-color-waypoint-gradient-faint-start);
--inset-border-color: var(--token-color-waypoint-border);
}

.theme-vagrant {
--gradient-start: var(--token-color-vagrant-gradient-faint-stop);
--gradient-stop: var(--token-color-vagrant-gradient-faint-start);
--inset-border-color: var(--token-color-vagrant-border);
}
69 changes: 69 additions & 0 deletions src/views/open-api-docs-view-v2/components/sidebar-link/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/

// Components
import Link from '@components/link'
import Text from '@components/text'
// Types
import type { PropsWithChildren } from 'react'
import type { LinkProps } from '@components/link'
// Styles
import s from './style.module.css'
import classNames from 'classnames'

/**
* Render a link element styled for use in our sidebar.
*
* Note: this component is based on the existing SidebarNavLinkItem. This
* component aims to render similar link elements through a more composable
* interface. We could in theory start to replace SidebarNavLinkItem with
* this component. For now, focus is on delivering API docs, so intent here
* is more narrow, hopefully this component will make the new API docs easier
* to maintain and iterate on by decoupling from our existing component. If
* we're happy with the pattern, then we could adopt it elsewhere.
*
* If further functionality from SidebarNavLinkItem is required in this
* context, it may be worth first trying to build the desired use case by
* composing existing pieces (such as SidebarLink and SidebarLinkText).
* For example, an "external link" component could be something like
* `<SidebarLink><SidebarLinkText /><IconExternalLink16 /></SidebarLink>`.
*
* If composed patterns are repeated _exactly_, and in a way where we'd
* definitely want to update all instances at once, then it may be worth
* creating a re-usable component that captures the composition pattern.
*
* A component that captures a common pattern of composition should _not_ need
* any conditional statements. The props interface should be very simple - the
* point being to _reduce_ the complexity of repeating the identical composed
* pattern. If "edge cases" need to be handled, then the consumer can "eject"
* from the composed pattern by copying and pasting the body of the composed
* component and making changes from there.
*/
export function SidebarLink({
children,
/**
* We merge the className prop with our own styles. This allows consumers
* to target the `<a />` element rendered by `<Link />`.
*/
className,
...linkProps
}: PropsWithChildren<LinkProps>) {
return (
<Link {...linkProps} className={classNames(s.sidebarLink, className)}>
{children}
</Link>
)
}

/**
* Render a span element with text styles that fit with SidebarLink.
*/
export function SidebarLinkText({ children }) {
return (
<Text asElement="span" size={200} weight="regular">
{children}
</Text>
)
}
Loading

0 comments on commit 7ea293b

Please sign in to comment.