Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Product switcher #2016

Draft
wants to merge 17 commits into
base: main
Choose a base branch
from
Draft
8 changes: 8 additions & 0 deletions packages/fdr-sdk/src/navigation/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { RootNode } from "../client/generated/api/resources/navigation/resources/v1/types";
import { ApiDefinitionHolder } from "./ApiDefinitionHolder";
import { ApiDefinitionPruner } from "./ApiDefinitionPruner";
import { ApiTypeIdVisitor } from "./ApiTypeIdVisitor";
Expand All @@ -15,3 +16,10 @@ export {
ApiTypeIdVisitor,
NodeCollector,
};

/**
* Type guard to check if a node is a ProductGroupNode
*/
export function isProductGroup(node: RootNode): boolean {
return node.type === "productgroup";
}
11 changes: 11 additions & 0 deletions packages/fdr-sdk/src/navigation/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,14 @@ export * from "./toApis";
export * from "./toPages";
export * from "./toRootNode";
export * from "./toUnversionedSlug";

/**
* Removes the product prefix from a slug
* e.g. "product1/v1/foo/bar" -> "v1/foo/bar"
*/
export function toUnproductedSlug(slug: string, productSlug: string): string {
if (slug.startsWith(productSlug + "/")) {
return slug.slice(productSlug.length + 1);
}
return slug;
}
140 changes: 139 additions & 1 deletion packages/fern-docs/bundle/src/server/withInitialProps.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as FernNavigation from "@fern-api/fdr-sdk/navigation";
import { withDefaultProtocol } from "@fern-api/ui-core-utils";
import { isNonNullish, withDefaultProtocol } from "@fern-api/ui-core-utils";
import visitDiscriminatedUnion from "@fern-api/ui-core-utils/visitDiscriminatedUnion";
import {
getAuthEdgeConfig,
Expand Down Expand Up @@ -51,6 +51,121 @@ interface WithInitialProps {
rawCookie: string | undefined;
}

interface WithProductSwitcherInfoArgs {
/**
* The current node to mutate the product switcher info for.
*/
node: FernNavigation.NavigationNodeWithMetadata;

/**
* The parents of the current node, in descending order.
*/
parents: readonly FernNavigation.NavigationNode[];

/**
* All available products to be rendered in the product switcher.
*/
products: readonly FernNavigation.ProductNode[];

/**
* A map of slugs to nodes for ALL nodes in the tree.
* This is used to check if a node exists in a different product.
*/
slugMap: Map<string, FernNavigation.NavigationNodeWithMetadata>;
}

/**
* Similar to version switching, we want to preserve the "context" of the current node when switching between products.
* For example, if the current node is `/docs/product1/v1/foo/bar`, and the user switches to `/docs/product2`, we need to find
* if `/docs/product2/v1/foo/bar` exists. If it doesn't, we can try `/docs/product2/v1/foo` and so on.
*/
function withProductSwitcherInfo({
node,
parents,
products,
slugMap,
}: WithProductSwitcherInfoArgs): FernNavigation.ProductNode[] {
const { product: currentProduct, nodes } =
getNodesUnderCurrentProductAscending(node, parents);

const unproductedSlugs =
currentProduct == null
? []
: nodes
.filter(FernNavigation.hasMetadata)
.map((node) => node.slug)
.map((slug) =>
FernNavigation.utils.toUnproductedSlug(slug, currentProduct.slug)
);

return products
.filter((product) => !product.hidden)
.map((product) => {
if (product.productId === currentProduct?.productId) {
return {
...product,
pointsTo: node.slug,
};
}

const expectedSlugs = unproductedSlugs.map((slug) =>
FernNavigation.slugjoin(product.slug, slug)
);

const expectedSlug = expectedSlugs
.map((slug) => {
const node = slugMap.get(slug);

if (node == null) {
return undefined;
} else if (FernNavigation.isPage(node)) {
return node.slug;
} else if (FernNavigation.hasRedirect(node)) {
return node.pointsTo;
}

return undefined;
})
.filter(isNonNullish)[0];

// If we can't find a matching page in the target product, default to the product's landing page
const pointsTo = expectedSlug ?? product.pointsTo ?? product.slug;

return {
...product,
pointsTo,
};
});
}

function getNodesUnderCurrentProductAscending(
node: FernNavigation.NavigationNodeWithMetadata,
parents: readonly FernNavigation.NavigationNode[]
): {
product: FernNavigation.ProductNode | undefined;
nodes: FernNavigation.NavigationNodeWithMetadata[];
} {
const currentProductIndex = parents.findIndex(
(node) => node.type === "product"
);

if (currentProductIndex < 0) {
return { product: undefined, nodes: [] };
}

const product = parents[currentProductIndex];
if (product == null || product.type !== "product") {
return { product: undefined, nodes: [] };
}

parents = parents.slice(currentProductIndex + 1);

return {
product: product as FernNavigation.ProductNode,
nodes: [...parents, node].filter(FernNavigation.hasMetadata).reverse(),
};
}

export async function withInitialProps({
docs: docsResponse,
slug,
Expand Down Expand Up @@ -346,6 +461,27 @@ export async function withInitialProps({
const engine = edgeFlags.useMdxBundler ? "mdx-bundler" : "next-mdx-remote";
const serializeMdx = await getMdxBundler(engine);

// Get all products from the root node
const products =
(root as { type: string }).type === "productgroup"
? (root as unknown as { children: FernNavigation.ProductNode[] }).children
: [];

// Add product switcher info only if we have products
const { product: currentProduct } = getNodesUnderCurrentProductAscending(
found.node,
found.parents
);
const productSwitcher =
products.length > 0
? withProductSwitcherInfo({
node: found.node,
parents: found.parents,
products,
slugMap: found.collector.slugMap,
})
: [];

const props: ComponentProps<typeof DocsPage> = {
baseUrl: docs.baseUrl,
layout: docs.definition.config.layout,
Expand All @@ -370,6 +506,8 @@ export async function withInitialProps({
tabs,
currentVersionId,
versions,
currentProductId: currentProduct?.productId,
products: productSwitcher,
sidebar,
trailingSlash: isTrailingSlashEnabled(),
},
Expand Down
2 changes: 2 additions & 0 deletions packages/fern-docs/ui/src/atoms/docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ export const EMPTY_DOCS_STATE: DocsProps = {
versions: [],
sidebar: undefined,
trailingSlash: false,
currentProductId: undefined,
products: [],
},
title: undefined,
favicon: undefined,
Expand Down
104 changes: 95 additions & 9 deletions packages/fern-docs/ui/src/atoms/navigation.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import type { ApiDefinition } from "@fern-api/fdr-sdk/api-definition";
import * as FernNavigation from "@fern-api/fdr-sdk/navigation";
import { SidebarTab, VersionSwitcherInfo } from "@fern-platform/fdr-utils";
import { SidebarTab } from "@fern-platform/fdr-utils";
import { isEqual } from "es-toolkit/predicate";
import { atom, useAtomValue } from "jotai";
import { selectAtom } from "jotai/utils";
import { mockProducts } from "../mocks/products";
import { DocsContent } from "../resolver/DocsContent";
import { DOCS_ATOM } from "./docs";
import { SLUG_ATOM } from "./location";
Expand All @@ -24,21 +25,103 @@ export const TABS_ATOM = selectAtom(
);
TABS_ATOM.debugLabel = "TABS_ATOM";

export const VERSIONS_ATOM = selectAtom(
DOCS_ATOM,
(docs): readonly VersionSwitcherInfo[] => docs.navigation.versions,
isEqual
);
export const VERSIONS_ATOM = atom((get) => {
const products = get(PRODUCTS_ATOM);

const versions = products.flatMap((product) =>
product.child.type === "versioned" ? product.child.children : []
);

return versions;
});
VERSIONS_ATOM.debugLabel = "VERSIONS_ATOM";

export const CURRENT_TAB_INDEX_ATOM = atom<number | undefined>(
(get) => get(DOCS_ATOM).navigation.currentTabIndex
);
CURRENT_TAB_INDEX_ATOM.debugLabel = "CURRENT_TAB_INDEX_ATOM";

export const CURRENT_VERSION_ID_ATOM = atom<
// Jotai recipe: atomWithRefreshAndDefault https://jotai.org/docs/recipes/atom-with-refresh-and-default
const PRODUCT_REFRESH_ATOM = atom(0);
const SETTABLE_CURRENT_PRODUCT_ID_ATOM = atom<
FernNavigation.ProductId | undefined
>(undefined);
SETTABLE_CURRENT_PRODUCT_ID_ATOM.debugLabel =
"SETTABLE_CURRENT_PRODUCT_ID_ATOM";
export const CURRENT_PRODUCT_ID_ATOM = (() => {
const overwrittenAtom = atom<{
refresh: number;
value: FernNavigation.ProductId | undefined;
} | null>(null);

return atom(
(get) => {
const lastState = get(overwrittenAtom);
if (lastState && lastState.refresh === get(PRODUCT_REFRESH_ATOM)) {
return lastState.value;
}
const products = get(PRODUCTS_ATOM);
return (
get(SETTABLE_CURRENT_PRODUCT_ID_ATOM) ??
products[0]?.id ??
products[0]?.productId
);
},
(get, set, update: FernNavigation.ProductId | undefined) => {
set(overwrittenAtom, {
refresh: get(PRODUCT_REFRESH_ATOM),
value: update,
});
set(SETTABLE_CURRENT_PRODUCT_ID_ATOM, update);
}
);
})();
CURRENT_PRODUCT_ID_ATOM.debugLabel = "CURRENT_PRODUCT_ID_ATOM";

export const FILTERED_VERSIONS_ATOM = atom((get) => {
const versions = get(VERSIONS_ATOM);
const currentProductId = get(CURRENT_PRODUCT_ID_ATOM);

if (currentProductId == null) {
return versions;
}

return versions.filter((version) =>
version.slug.startsWith(currentProductId)
);
});
FILTERED_VERSIONS_ATOM.debugLabel = "FILTERED_VERSIONS_ATOM";

const VERSION_REFRESH_ATOM = atom(0);
const SETTABLE_CURRENT_VERSION_ID_ATOM = atom<
FernNavigation.VersionId | undefined
>((get) => get(DOCS_ATOM).navigation.currentVersionId);
>(undefined);
SETTABLE_CURRENT_VERSION_ID_ATOM.debugLabel =
"SETTABLE_CURRENT_VERSION_ID_ATOM";
export const CURRENT_VERSION_ID_ATOM = (() => {
const overwrittenAtom = atom<{
refresh: number;
value: FernNavigation.VersionId | undefined;
} | null>(null);

return atom(
(get) => {
const lastState = get(overwrittenAtom);
if (lastState && lastState.refresh === get(VERSION_REFRESH_ATOM)) {
return lastState.value;
}
const filteredVersions = get(FILTERED_VERSIONS_ATOM);
return get(SETTABLE_CURRENT_VERSION_ID_ATOM) ?? filteredVersions[0]?.id;
},
(get, set, update: FernNavigation.VersionId | undefined) => {
set(overwrittenAtom, {
refresh: get(VERSION_REFRESH_ATOM),
value: update,
});
set(SETTABLE_CURRENT_VERSION_ID_ATOM, update);
}
);
})();
CURRENT_VERSION_ID_ATOM.debugLabel = "CURRENT_VERSION_ID_ATOM";

export const TRAILING_SLASH_ATOM = atom<boolean>(
Expand All @@ -56,7 +139,7 @@ NAVBAR_LINKS_ATOM.debugLabel = "NAVBAR_LINKS_ATOM";
export const CURRENT_VERSION_ATOM = atom((get) => {
const versionId = get(CURRENT_VERSION_ID_ATOM);
const versions = get(VERSIONS_ATOM);
return versions.find((v) => v.id === versionId);
return versions.find((v) => v.versionId === versionId);
});
CURRENT_VERSION_ATOM.debugLabel = "CURRENT_VERSION_ATOM";

Expand Down Expand Up @@ -156,3 +239,6 @@ export function useDomain(): string {
export function useBasePath(): string | undefined {
return useAtomValue(BASEPATH_ATOM);
}

export const PRODUCTS_ATOM = selectAtom(DOCS_ATOM, () => mockProducts, isEqual);
PRODUCTS_ATOM.debugLabel = "PRODUCTS_ATOM";
2 changes: 2 additions & 0 deletions packages/fern-docs/ui/src/atoms/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ export interface NavigationProps {
tabs: SidebarTab[];
currentVersionId: FernNavigation.VersionId | undefined;
versions: VersionSwitcherInfo[];
currentProductId: FernNavigation.ProductId | undefined;
products: FernNavigation.ProductNode[];
sidebar: FernNavigation.SidebarRootNode | undefined;
trailingSlash: boolean;
}
Expand Down
9 changes: 9 additions & 0 deletions packages/fern-docs/ui/src/header/Header.scss
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,13 @@
@apply flex items-center lg:hidden;
}
}

.hide-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}

.hide-scrollbar::-webkit-scrollbar {
display: none;
}
}
Loading
Loading