diff --git a/src/components/navigation-header/components/product-page-content/utils/get-nav-items.ts b/src/components/navigation-header/components/product-page-content/utils/get-nav-items.ts index 198bb78f6e..114862afe2 100644 --- a/src/components/navigation-header/components/product-page-content/utils/get-nav-items.ts +++ b/src/components/navigation-header/components/product-page-content/utils/get-nav-items.ts @@ -146,6 +146,13 @@ export function getNavItems(currentProduct: ProductData): NavItem[] { } } + if (currentProduct.playgroundConfig?.labs?.length) { + items.push({ + label: 'Playground', + url: `/${currentProduct.slug}/playground`, + }) + } + /** * For Terraform, add a "Registry" item */ diff --git a/src/contexts/instruqt-lab/index.tsx b/src/contexts/instruqt-lab/index.tsx index 8914b28692..b2186eed93 100644 --- a/src/contexts/instruqt-lab/index.tsx +++ b/src/contexts/instruqt-lab/index.tsx @@ -23,6 +23,8 @@ interface InstruqtContextProps { interface InstruqtProviderProps { labId: string children: ReactNode + defaultActive?: boolean + isPlayground?: boolean } const InstruqtContext = createContext<Partial<InstruqtContextProps>>({}) @@ -34,23 +36,31 @@ export const useInstruqtEmbed = (): Partial<InstruqtContextProps> => export default function InstruqtProvider({ labId, children, + defaultActive = false, + isPlayground = false, }: InstruqtProviderProps): JSX.Element { - const [active, setActive] = useState(false) + const [active, setActive] = useState(defaultActive) return ( <InstruqtContext.Provider value={{ labId, active, setActive }}> - {children} - {active && ( - <div id="instruqt-panel-target"> - <Resizable - initialHeight={640} - panelActive={active} - setPanelActive={setActive} - style={{ top: '-28px' }} - > - <EmbedElement /> - </Resizable> - </div> + {isPlayground ? ( + children + ) : ( + <> + {children} + {active && ( + <div id="instruqt-panel-target"> + <Resizable + initialHeight={640} + panelActive={active} + setPanelActive={setActive} + style={{ top: '-28px' }} + > + <EmbedElement /> + </Resizable> + </div> + )} + </> )} </InstruqtContext.Provider> ) diff --git a/src/data/nomad.json b/src/data/nomad.json index b4ec3ec91b..76485a6711 100644 --- a/src/data/nomad.json +++ b/src/data/nomad.json @@ -102,5 +102,32 @@ ], "integrationsConfig": { "description": "A curated collection of official, partner, and community Nomad Integrations." + }, + + "playgroundConfig": { + "description": "Learn how to manage your workloads with Nomad.", + "sidebarLinks": [ + { + "title": "Install Nomad", + "href": "/nomad/install" + }, + { + "title": "Get Started with Nomad", + "href": "/nomad/tutorials/get-started" + }, + { + "title": "Nomad documentation", + "href": "/nomad/docs" + } + ], + "labs": [ + { + "id": "nomad-sandbox", + "name": "Nomad sandbox", + "instruqtId": "hashicorp-learn/tracks/nomad-sandbox?token=em_0wOuIAyyjAQllLkc", + "description": "A Nomad cluster with three server nodes and one client node, Consul installed and configured, and Access Control Lists (ACLs) enabled for both Nomad and Consul.", + "products": ["nomad", "consul"] + } + ] } } diff --git a/src/data/playground-config.json b/src/data/playground-config.json new file mode 100644 index 0000000000..90a660ebda --- /dev/null +++ b/src/data/playground-config.json @@ -0,0 +1,23 @@ +{ + "terraform": { + "labId": "hashicorp-learn/tracks/terraform-build-your-first-configuration" + }, + "vault": { + "labId": "hashicorp-learn/tracks/vault-basics" + }, + "consul": { + "labId": "hashicorp-learn/tracks/consul-template-automate-reverse-proxy-config" + }, + "nomad": { + "labId": "hashicorp-learn/tracks/nomad-basics" + }, + "packer": { + "labId": "hashicorp-learn/tracks/packer-get-started-hcp" + }, + "waypoint": { + "labId": "hashicorp-learn/tracks/waypoint-get-started-docker" + }, + "boundary": { + "labId": "hashicorp-learn/tracks/boundary-basics" + } +} diff --git a/src/data/terraform.json b/src/data/terraform.json index 1c6de4f9c6..9bc211a2f7 100644 --- a/src/data/terraform.json +++ b/src/data/terraform.json @@ -151,5 +151,38 @@ "path": "registry", "productSlugForLoader": "terraform-docs-common" } - ] + ], + "playgroundConfig": { + "description": "Learn how to create infrastructure with Terraform", + "sidebarLinks": [ + { + "title": "Install Terraform", + "href": "/terraform/install" + }, + { + "title": "Get Started with Terraform", + "href": "/terraform/tutorials/aws-get-started" + }, + { + "title": "Terraform documentation", + "href": "/terraform/docs" + } + ], + "labs": [ + { + "id": "create-infrastructure", + "name": "Create Infrastructure", + "instruqtId": "hashicorp-learn/tracks/create-terraform-infrastructure?token=em__EA8k5ywxqiOejXd", + "description": "Learn how to create infrastructure with Terraform", + "products": ["terraform"] + }, + { + "id": "vault-sandbox", + "name": "Vault Playground (test)", + "instruqtId": "hashicorp-learn/tracks/vault-sandbox?token=em_usmVkoZLWz8SAXNB", + "description": "Learn how to manage your secrets with Vault", + "products": ["vault"] + } + ] + } } diff --git a/src/data/vault.json b/src/data/vault.json index e6bb0fe83c..4a4bd6190d 100644 --- a/src/data/vault.json +++ b/src/data/vault.json @@ -174,5 +174,37 @@ "href": "/vault/tutorials/custom-secrets-engine" } ] + }, + "playgroundConfig": { + "sidebarLinks": [ + { + "title": "Install Vault", + "href": "/vault/install" + }, + { + "title": "Get Started with Vault", + "href": "/vault/tutorials/get-started" + }, + { + "title": "Vault Documentation", + "href": "/vault/docs" + } + ], + "labs": [ + { + "id": "vault-sandbox", + "name": "Vault Playground (test)", + "instruqtId": "hashicorp-learn/tracks/vault-sandbox?token=em_usmVkoZLWz8SAXNB", + "description": "Learn how to manage your secrets with Vault", + "products": ["vault"] + }, + { + "id": "create-infrastructure", + "name": "Create Infrastructure", + "instruqtId": "hashicorp-learn/tracks/create-terraform-infrastructure?token=em__EA8k5ywxqiOejXd", + "description": "Learn how to create infrastructure with Terraform", + "products": ["terraform"] + } + ] } } diff --git a/src/pages/[productSlug]/playground/[playgroundId].tsx b/src/pages/[productSlug]/playground/[playgroundId].tsx new file mode 100644 index 0000000000..ae10e1b731 --- /dev/null +++ b/src/pages/[productSlug]/playground/[playgroundId].tsx @@ -0,0 +1,181 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import { GetStaticPaths, GetStaticProps } from 'next' +import { PRODUCT_DATA_MAP } from 'data/product-data-map' +import SidebarSidecarLayout from 'layouts/sidebar-sidecar' +import InstruqtProvider from 'contexts/instruqt-lab' +import EmbedElement from 'components/lab-embed/embed-element' +import { + generateTopLevelSidebarNavData, + generateProductLandingSidebarNavData, +} from 'components/sidebar/helpers' + +interface PlaygroundPageProps { + product: (typeof PRODUCT_DATA_MAP)[keyof typeof PRODUCT_DATA_MAP] + playgroundId: string + playgroundName: string + playgroundDescription: string + layoutProps: { + breadcrumbLinks: { title: string; url: string }[] + navLevels: any[] + } +} + +export default function PlaygroundView({ + product, + playgroundId, + playgroundName, + playgroundDescription, + layoutProps, +}: PlaygroundPageProps) { + return ( + <SidebarSidecarLayout + breadcrumbLinks={layoutProps.breadcrumbLinks} + sidebarNavDataLevels={layoutProps.navLevels} + > + <div> + <h1 className="g-type-display-3">{playgroundName}</h1> + <p className="g-type-body" style={{ marginTop: '16px' }}> + {playgroundDescription} + </p> + </div> + <div style={{ height: '80vh', marginTop: '32px' }}> + <InstruqtProvider labId={playgroundId} defaultActive isPlayground> + <EmbedElement /> + </InstruqtProvider> + </div> + </SidebarSidecarLayout> + ) +} + +export const getStaticPaths: GetStaticPaths = async () => { + const paths = [] + + // Generate paths for each product's playgrounds + Object.values(PRODUCT_DATA_MAP).forEach((product) => { + if (product.playgroundConfig?.labs) { + product.playgroundConfig.labs.forEach((playground) => { + paths.push({ + params: { + productSlug: product.slug, + playgroundId: playground.id, + }, + }) + }) + } + }) + + return { + paths, + fallback: false, + } +} + +export const getStaticProps: GetStaticProps<PlaygroundPageProps> = async ({ + params, +}) => { + const productSlug = params?.productSlug as string + const playgroundId = params?.playgroundId as string + const product = PRODUCT_DATA_MAP[productSlug] + + // Only show playground page if product has labs configured + if (!product || !product.playgroundConfig?.labs) { + return { + notFound: true, + } + } + + const playground = product.playgroundConfig.labs.find( + (p) => p.id === playgroundId + ) + if (!playground) { + return { + notFound: true, + } + } + + const breadcrumbLinks = [ + { title: 'Developer', url: '/' }, + { title: product.name, url: `/${productSlug}` }, + { title: 'Playground', url: `/${productSlug}/playground` }, + { + title: playground.name, + url: `/${productSlug}/playground/${playgroundId}`, + }, + ] + + const sidebarNavDataLevels = [ + generateTopLevelSidebarNavData(product.name), + generateProductLandingSidebarNavData(product), + ] + + // Add playground links + const playgroundMenuItems = [ + { + title: `${product.name} Playground`, + fullPath: `/${productSlug}/playground`, + theme: product.slug, + isActive: false, + }, + { + divider: true, + }, + { + heading: 'Playgrounds', + }, + ...product.playgroundConfig.labs.map((p) => ({ + title: p.name, + path: `/${productSlug}/playground/${p.id}`, + href: `/${productSlug}/playground/${p.id}`, + isActive: p.id === playgroundId, + })), + ] + + if (product.playgroundConfig.sidebarLinks) { + playgroundMenuItems.push( + { + divider: true, + }, + { + heading: 'Resources', + }, + ...product.playgroundConfig.sidebarLinks.map((link) => ({ + title: link.title, + path: link.href, + href: link.href, + isActive: false, + })) + ) + } + + sidebarNavDataLevels.push({ + backToLinkProps: { + text: `${product.name} Home`, + href: `/${product.slug}`, + }, + title: 'Playground', + menuItems: playgroundMenuItems, + showFilterInput: false, + visuallyHideTitle: true, + levelButtonProps: { + levelUpButtonText: `${product.name} Home`, + levelDownButtonText: 'Previous', + }, + }) + + return { + props: { + product, + playgroundId: playground.instruqtId, + playgroundName: playground.name, + playgroundDescription: playground.description, + layoutProps: { + breadcrumbLinks, + navLevels: sidebarNavDataLevels, + }, + }, + } +} diff --git a/src/pages/[productSlug]/playground/index.tsx b/src/pages/[productSlug]/playground/index.tsx new file mode 100644 index 0000000000..e32edd1d94 --- /dev/null +++ b/src/pages/[productSlug]/playground/index.tsx @@ -0,0 +1,181 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import { GetStaticPaths, GetStaticProps } from 'next' +import { PRODUCT_DATA_MAP } from 'data/product-data-map' +import SidebarSidecarLayout from 'layouts/sidebar-sidecar' +import { + generateTopLevelSidebarNavData, + generateProductLandingSidebarNavData, +} from 'components/sidebar/helpers' +import CardLink from 'components/card-link' +import { + CardTitle, + CardDescription, + CardFooter, +} from 'components/card/components' +import CardsGridList from 'components/cards-grid-list' +import { BrandedHeaderCard } from 'views/product-integrations-landing/components/branded-header-card' +import { CardBadges } from 'components/tutorial-collection-cards' +import { ProductOption } from 'lib/learn-client/types' + +interface PlaygroundPageProps { + product: (typeof PRODUCT_DATA_MAP)[keyof typeof PRODUCT_DATA_MAP] + layoutProps: { + breadcrumbLinks: { title: string; url: string }[] + navLevels: any[] + } +} + +export default function PlaygroundView({ + product, + layoutProps, +}: PlaygroundPageProps) { + return ( + <SidebarSidecarLayout + breadcrumbLinks={layoutProps.breadcrumbLinks} + sidebarNavDataLevels={layoutProps.navLevels} + > + <BrandedHeaderCard + productSlug={product.slug} + heading={`${product.name} Interactive Playgrounds`} + description="Choose a playground to get started with hands-on learning." + /> + + {product.playgroundConfig.description && ( + <p className="g-type-body" style={{ marginTop: '32px' }}> + {product.playgroundConfig.description} + </p> + )} + + <div style={{ marginTop: '32px' }}> + <CardsGridList fixedColumns={2}> + {product.playgroundConfig.labs.map((playground) => ( + <CardLink + key={playground.id} + ariaLabel={playground.name} + href={`/${product.slug}/playground/${playground.id}`} + > + <CardTitle text={playground.name} /> + {playground.description && ( + <CardDescription text={playground.description} /> + )} + <CardFooter> + <CardBadges + badges={playground.products.map( + (slug) => ProductOption[slug] + )} + /> + </CardFooter> + </CardLink> + ))} + </CardsGridList> + </div> + </SidebarSidecarLayout> + ) +} + +export const getStaticPaths: GetStaticPaths = async () => { + // Only generate paths for products that have labs configured + const paths = Object.values(PRODUCT_DATA_MAP) + .filter((product) => product.playgroundConfig?.labs) + .map((product) => ({ + params: { productSlug: product.slug }, + })) + + return { + paths, + fallback: false, + } +} + +export const getStaticProps: GetStaticProps<PlaygroundPageProps> = async ({ + params, +}) => { + const productSlug = params?.productSlug as string + const product = PRODUCT_DATA_MAP[productSlug] + + // Only show playground page if product has labs configured + if (!product || !product.playgroundConfig?.labs) { + return { + notFound: true, + } + } + + const breadcrumbLinks = [ + { title: 'Developer', url: '/' }, + { title: product.name, url: `/${productSlug}` }, + { title: 'Playground', url: `/${productSlug}/playground` }, + ] + + const sidebarNavDataLevels = [ + generateTopLevelSidebarNavData(product.name), + generateProductLandingSidebarNavData(product), + ] + + // Add playground links + const playgroundMenuItems = [ + { + title: `${product.name} Playground`, + fullPath: `/${productSlug}/playground`, + theme: product.slug, + isActive: true, + }, + { + divider: true, + }, + { + heading: 'Playgrounds', + }, + ...product.playgroundConfig.labs.map((playground) => ({ + title: playground.name, + path: `/${productSlug}/playground/${playground.id}`, + href: `/${productSlug}/playground/${playground.id}`, + isActive: false, + })), + ] + + if (product.playgroundConfig.sidebarLinks) { + playgroundMenuItems.push( + { + divider: true, + }, + { + heading: 'Resources', + }, + ...product.playgroundConfig.sidebarLinks.map((link) => ({ + title: link.title, + path: link.href, + href: link.href, + isActive: false, + })) + ) + } + + sidebarNavDataLevels.push({ + backToLinkProps: { + text: `${product.name} Home`, + href: `/${product.slug}`, + }, + title: 'Playground', + menuItems: playgroundMenuItems, + showFilterInput: false, + visuallyHideTitle: true, + levelButtonProps: { + levelUpButtonText: `${product.name} Home`, + levelDownButtonText: 'Previous', + }, + }) + + return { + props: { + product, + layoutProps: { + breadcrumbLinks, + navLevels: sidebarNavDataLevels, + }, + }, + } +} diff --git a/src/types/products.ts b/src/types/products.ts index b3259e0939..55bd2ce04e 100644 --- a/src/types/products.ts +++ b/src/types/products.ts @@ -148,6 +148,20 @@ interface ProductData extends Product { } basePaths: string[] rootDocsPaths: RootDocsPath[] + playgroundConfig?: { + sidebarLinks: { + title: string + href: string + }[] + description?: string + labs?: { + id: string + name: string + instruqtId: string + description: string + products: ProductSlug[] + }[] + } /** * When configuring docsNavItems, authors have the option to specify * the full data structure, or use a string that matches a rootDocsPath.path diff --git a/src/views/playground/index.tsx b/src/views/playground/index.tsx new file mode 100644 index 0000000000..3bd00f1228 --- /dev/null +++ b/src/views/playground/index.tsx @@ -0,0 +1,39 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import { ProductData } from 'types/products' +import { BreadcrumbLink } from 'components/breadcrumb-bar' +import { SidebarProps } from 'components/sidebar/types' +import SidebarSidecarLayout from 'layouts/sidebar-sidecar' +import InstruqtProvider from 'contexts/instruqt-lab' +import EmbedElement from 'components/lab-embed/embed-element' + +interface PlaygroundViewProps { + product: ProductData + labId: string + layoutProps: { + breadcrumbLinks: BreadcrumbLink[] + navLevels: SidebarProps[] + } +} + +export default function PlaygroundView({ + product, + labId, + layoutProps, +}: PlaygroundViewProps) { + return ( + <SidebarSidecarLayout + breadcrumbLinks={layoutProps.breadcrumbLinks} + sidebarNavDataLevels={layoutProps.navLevels} + > + <div style={{ height: '80vh' }}> + <InstruqtProvider labId={labId} defaultActive isPlayground> + <EmbedElement /> + </InstruqtProvider> + </div> + </SidebarSidecarLayout> + ) +} diff --git a/src/views/playground/server.ts b/src/views/playground/server.ts new file mode 100644 index 0000000000..7946817d24 --- /dev/null +++ b/src/views/playground/server.ts @@ -0,0 +1,99 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import { GetStaticProps } from 'next' +import { PRODUCT_DATA_MAP } from 'data/product-data-map' +import { ProductData } from 'types/products' +import { BreadcrumbLink } from 'components/breadcrumb-bar' +import { SidebarProps } from 'components/sidebar/types' +import { + generateTopLevelSidebarNavData, + generateProductLandingSidebarNavData, +} from 'components/sidebar/helpers' + +interface PlaygroundPageProps { + product: ProductData + labId: string + layoutProps: { + breadcrumbLinks: BreadcrumbLink[] + navLevels: SidebarProps[] + } +} + +export const getStaticProps: GetStaticProps<PlaygroundPageProps> = async ({ + params, +}) => { + const productSlug = params?.productSlug as string + const product = PRODUCT_DATA_MAP[productSlug] + + // Only show playground page if product has labs configured + if (!product || !product.playgroundConfig?.labs?.length) { + return { + notFound: true, + } + } + + const defaultLab = product.playgroundConfig.labs[0] + + const breadcrumbLinks = [ + { title: 'Developer', url: '/' }, + { title: product.name, url: `/${productSlug}` }, + { title: 'Playground', url: `/${productSlug}/playground` }, + ] + + const sidebarNavDataLevels = [ + generateTopLevelSidebarNavData(product.name), + generateProductLandingSidebarNavData(product), + ] + + // Add playground links if configured + if (product.playgroundConfig?.sidebarLinks) { + const playgroundMenuItems = [ + { + title: `${product.name} Playground`, + fullPath: `/${productSlug}/playground`, + theme: product.slug, + isActive: true, + }, + { + divider: true, + }, + { + heading: 'Resources', + }, + ...product.playgroundConfig.sidebarLinks.map((link) => ({ + title: link.title, + path: link.href, + href: link.href, + isActive: false, + })), + ] + sidebarNavDataLevels.push({ + backToLinkProps: { + text: `${product.name} Home`, + href: `/${product.slug}`, + }, + title: 'Playground', + menuItems: playgroundMenuItems, + showFilterInput: false, + visuallyHideTitle: true, + levelButtonProps: { + levelUpButtonText: `${product.name} Home`, + levelDownButtonText: 'Previous', + }, + }) + } + + return { + props: { + product, + labId: defaultLab.instruqtId, + layoutProps: { + breadcrumbLinks, + navLevels: sidebarNavDataLevels, + }, + }, + } +}