From c43e14171194567f089ccb68bc9ff759acf1fcbd Mon Sep 17 00:00:00 2001 From: Fredrik Ekholdt Date: Sun, 17 May 2026 21:25:34 +0200 Subject: [PATCH 1/3] UI improvements of pages nav menu --- .../ui/spa/components/NavMenu/NewPageForm.tsx | 404 ++++++++++++++++++ .../ui/spa/components/NavMenu/SitemapItem.tsx | 359 ++++++++-------- .../spa/components/NavMenu/SitemapSection.tsx | 147 ++++++- packages/ui/spa/components/NavMenu/index.ts | 1 + .../ui/spa/components/NavMenu/mockData.ts | 70 +++ .../NavMenu/stories/NewPageForm.stories.tsx | 110 +++++ .../stories/SitemapSection.stories.tsx | 109 ++++- 7 files changed, 997 insertions(+), 203 deletions(-) create mode 100644 packages/ui/spa/components/NavMenu/NewPageForm.tsx create mode 100644 packages/ui/spa/components/NavMenu/stories/NewPageForm.stories.tsx diff --git a/packages/ui/spa/components/NavMenu/NewPageForm.tsx b/packages/ui/spa/components/NavMenu/NewPageForm.tsx new file mode 100644 index 000000000..8b689e965 --- /dev/null +++ b/packages/ui/spa/components/NavMenu/NewPageForm.tsx @@ -0,0 +1,404 @@ +import { useEffect, useMemo, useState } from "react"; +import { ModuleFilePath } from "@valbuild/core"; +import { RoutePattern } from "@valbuild/shared/internal"; +import { cn } from "../designSystem/cn"; +import { Button } from "../designSystem/button"; + +/** + * A route a user can create new pages under. + */ +export type AvailableRoute = { + moduleFilePath: ModuleFilePath; + routePattern: RoutePattern[]; + /** Human-readable pattern, e.g. "/blogs/[blog]". */ + patternString: string; + /** Existing URL paths that would collide if reused. */ + existingKeys: string[]; +}; + +export type NewPageFormProps = { + /** One or more routes the user can create pages under. */ + routes: AvailableRoute[]; + /** Called with the moduleFilePath and the fully built URL path. */ + onSubmit: (moduleFilePath: ModuleFilePath, urlPath: string) => void; + /** Called when the user dismisses the form. */ + onCancel: () => void; +}; + +/** + * Form for creating a new page in the sitemap. + * + * Supports: + * - single dynamic segment routes (`/blogs/[blog]`) + * - multi-segment routes (`/products/[category]/[product]`) + * - catch-all routes (`/docs/[...slug]`) + * + * Visual treatment matches the redesigned sitemap: the static parts of the route + * are rendered as non-editable chips, dynamic parts as inputs. + */ +export function NewPageForm({ routes, onSubmit, onCancel }: NewPageFormProps) { + const [selectedRouteKey, setSelectedRouteKey] = useState(() => + routeKey(routes[0]), + ); + const selectedRoute = useMemo( + () => + routes.find((r) => routeKey(r) === selectedRouteKey) ?? routes[0] ?? null, + [routes, selectedRouteKey], + ); + + const [paramsByRoute, setParamsByRoute] = useState< + Record> + >({}); + const [errorsByRoute, setErrorsByRoute] = useState< + Record> + >({}); + + const params = selectedRoute + ? (paramsByRoute[routeKey(selectedRoute)] ?? {}) + : {}; + const errors = selectedRoute + ? (errorsByRoute[routeKey(selectedRoute)] ?? {}) + : {}; + + const fullPath = useMemo(() => { + if (!selectedRoute) return ""; + return buildFullPath(selectedRoute.routePattern, params); + }, [selectedRoute, params]); + + const isComplete = useMemo(() => { + if (!selectedRoute) return false; + return selectedRoute.routePattern.every((part) => { + if (part.type === "string-param" || part.type === "array-param") { + return !!params[part.paramName] && !errors[part.paramName]; + } + return true; + }); + }, [selectedRoute, params, errors]); + + const alreadyExists = + !!selectedRoute && selectedRoute.existingKeys.includes(fullPath); + const disabled = !isComplete || alreadyExists; + + // Focus the first dynamic input when the selected route changes. + // We rely on autoFocus on render, so changing the key forces remount. + const inputRenderKey = selectedRoute ? routeKey(selectedRoute) : ""; + + if (!selectedRoute) { + return ( +
+ No routes accept new pages. +
+ ); + } + + return ( +
{ + e.preventDefault(); + if (disabled) return; + onSubmit(selectedRoute.moduleFilePath, fullPath); + }} + > +
New page
+ + {routes.length > 1 ? ( +
+ + setSelectedRouteKey(value)} + /> +
+ ) : ( +
+ Route + +
+ )} + +
+ URL + { + const key = routeKey(selectedRoute); + setParamsByRoute((prev) => ({ + ...prev, + [key]: { ...(prev[key] ?? {}), [paramName]: value }, + })); + setErrorsByRoute((prev) => ({ + ...prev, + [key]: { ...(prev[key] ?? {}), [paramName]: error }, + })); + }} + /> + +
+ + {alreadyExists && ( +

+ A page with this path already exists +

+ )} + +
+ + +
+
+ ); +} + +function routeKey(route: AvailableRoute | undefined): string { + if (!route) return ""; + return `${route.moduleFilePath}::${route.patternString}`; +} + +/** + * Build the full URL path from a route pattern and the user-filled params. + * Mirrors the behavior of the old AddRouteForm. + */ +function buildFullPath( + pattern: RoutePattern[], + params: Record, +): string { + return ( + "/" + + pattern + .map((part) => { + if (part.type === "string-param" || part.type === "array-param") { + return params[part.paramName] || ""; + } + return part.name; + }) + .join("/") + ); +} + +/** + * Render the route pattern as a non-editable display, with dynamic + * segments shown as purple pills. + */ +function RoutePatternDisplay({ pattern }: { pattern: RoutePattern[] }) { + return ( +
+ / + {pattern.map((part, i) => ( + + {part.type === "literal" ? ( + {part.name} + ) : ( + + )} + {i < pattern.length - 1 && ( + / + )} + + ))} +
+ ); +} + +/** + * Render the route pattern as a mix of static prefix chips and editable + * inputs for dynamic segments. + */ +function RoutePatternInputs({ + pattern, + params, + errors, + onChange, +}: { + pattern: RoutePattern[]; + params: Record; + errors: Record; + onChange: ( + paramName: string, + value: string, + error: string | undefined, + ) => void; +}) { + // Group adjacent literals so they render as a single chip like "/blogs/". + const groups = useMemo(() => groupPattern(pattern), [pattern]); + const firstDynamicIndex = useMemo( + () => groups.findIndex((g) => g.type !== "literal-run"), + [groups], + ); + + return ( +
+ {groups.map((group, i) => { + if (group.type === "literal-run") { + return ( + + /{group.parts.map((p) => p.name).join("/")} + {i < groups.length - 1 ? "/" : ""} + + ); + } + + const part = group.part; + const value = params[part.paramName] || ""; + const error = errors[part.paramName]; + const isCatchAll = part.type === "array-param"; + + return ( + + { + const next = e.target.value; + const compare = isCatchAll ? next.replace(/\//g, "") : next; + const invalid = + next.length > 0 && encodeURIComponent(compare) !== compare; + onChange( + part.paramName, + next, + invalid ? "Invalid characters" : undefined, + ); + }} + /> + {error && ( + {error} + )} + + ); + })} +
+ ); +} + +type PatternGroup = + | { type: "literal-run"; parts: { name: string }[] } + | { + type: "dynamic"; + part: Extract< + RoutePattern, + { type: "string-param" } | { type: "array-param" } + >; + }; + +function groupPattern(pattern: RoutePattern[]): PatternGroup[] { + const groups: PatternGroup[] = []; + let currentRun: { name: string }[] | null = null; + for (const part of pattern) { + if (part.type === "literal") { + if (!currentRun) { + currentRun = []; + groups.push({ type: "literal-run", parts: currentRun }); + } + currentRun.push({ name: part.name }); + } else { + currentRun = null; + groups.push({ type: "dynamic", part }); + } + } + return groups; +} + +function DynamicSegmentPill({ + part, +}: { + part: Extract< + RoutePattern, + { type: "string-param" } | { type: "array-param" } + >; +}) { + const label = + part.type === "array-param" ? `...${part.paramName}` : part.paramName; + return ( + + [{label}] + + ); +} + +function RouteHint({ pattern }: { pattern: RoutePattern[] }) { + const hasCatchAll = pattern.some((p) => p.type === "array-param"); + const dynamicCount = pattern.filter( + (p) => p.type === "string-param" || p.type === "array-param", + ).length; + + if (hasCatchAll) { + return ( +

+ Catch-all segments can contain / for nested paths. +

+ ); + } + if (dynamicCount > 1) { + return ( +

+ Fill in each segment. Slashes will be added automatically. +

+ ); + } + return ( +

+ Use lowercase letters, numbers, and hyphens. +

+ ); +} + +/** + * Native select used for picking the route when multiple are available. + * Kept simple — the Radix Select needs a portal container and complicates the + * popover. A native select is a reasonable default for the count of routes + * a project will typically have. + */ +function RouteSelect({ + routes, + value, + onChange, +}: { + routes: AvailableRoute[]; + value: string; + onChange: (value: string) => void; +}) { + // Force the selected route to be valid if `routes` changes. + useEffect(() => { + if (!routes.find((r) => routeKey(r) === value) && routes[0]) { + onChange(routeKey(routes[0])); + } + }, [routes, value, onChange]); + + return ( + + ); +} diff --git a/packages/ui/spa/components/NavMenu/SitemapItem.tsx b/packages/ui/spa/components/NavMenu/SitemapItem.tsx index 0c9a50afc..55684848b 100644 --- a/packages/ui/spa/components/NavMenu/SitemapItem.tsx +++ b/packages/ui/spa/components/NavMenu/SitemapItem.tsx @@ -1,5 +1,7 @@ -import { ChevronRight, FileText, Folder, Plus } from "lucide-react"; +import { ChevronRight, Plus } from "lucide-react"; import { useState, useMemo, useRef, useEffect } from "react"; +import { ModuleFilePath } from "@valbuild/core"; +import { RoutePattern } from "@valbuild/shared/internal"; import { cn } from "../designSystem/cn"; import { AnimateHeight } from "../AnimateHeight"; import { SitemapItem as SitemapItemType } from "./types"; @@ -8,8 +10,7 @@ import { PopoverContent, PopoverTrigger, } from "../designSystem/popover"; -import { RoutePattern } from "@valbuild/shared/internal"; -import { Button } from "../designSystem/button"; +import { AvailableRoute, NewPageForm } from "./NewPageForm"; export type SitemapItemProps = { item: SitemapItemType; @@ -19,8 +20,12 @@ export type SitemapItemProps = { onNavigate?: (sourcePath: string) => void; /** Called when a new page should be added */ onAddPage?: (moduleFilePath: string, urlPath: string) => void; - /** Nesting depth for indentation */ + /** Existing URL paths (for duplicate validation in row-level add). */ + existingUrls?: string[]; + /** Nesting depth, used to indent the row */ depth?: number; + /** Accumulated URL path from ancestors (e.g. "/blogs"). */ + parentPath?: string; /** Container for portal (for popover) */ portalContainer?: HTMLElement | null; }; @@ -30,7 +35,9 @@ export function SitemapItemNode({ currentPath = "", onNavigate, onAddPage, + existingUrls, depth = 0, + parentPath = "", portalContainer, }: SitemapItemProps) { const [isOpen, setIsOpen] = useState(true); @@ -42,13 +49,48 @@ export function SitemapItemNode({ const isNavigable = !!item.sourcePath; const isActive = item.sourcePath && currentPath.startsWith(item.sourcePath); const isExactActive = item.sourcePath === currentPath; - const isFolder = hasChildren && !isNavigable; - // Scroll into view when this item becomes the exact active item + // The URL we render in this row. The root entry is just "/"; everything else + // is the parent path joined with this item's segment. + const displayUrl = useMemo(() => { + if (item.name === "/" || item.name === "") return "/"; + return `${parentPath}/${item.name}`; + }, [item.name, parentPath]); + + // The URL prefix from the parent — rendered in muted text so the segment for + // this row stands out. For root and top-level items there's no prefix. + const prefix = useMemo(() => { + if (displayUrl === "/") return ""; + if (parentPath === "") return "/"; + return `${parentPath}/`; + }, [displayUrl, parentPath]); + + const ownSegment = useMemo(() => { + if (displayUrl === "/") return "/"; + return item.name; + }, [displayUrl, item.name]); + + // The depth of this row in URL terms (root = 0, /blogs = 1, /blogs/x = 2). + // Used to find the next dynamic segment a user could create under this row. + const urlDepth = useMemo(() => { + if (displayUrl === "/") return 0; + return displayUrl.split("/").filter(Boolean).length; + }, [displayUrl]); + + const nextDynamicSegment = useMemo(() => { + if (!item.canAddChild || !item.routePattern) return null; + const part = item.routePattern[urlDepth]; + if (!part) return null; + if (part.type === "string-param" || part.type === "array-param") { + return part; + } + return null; + }, [item.canAddChild, item.routePattern, urlDepth]); + + // Scroll the active row into view, slightly delayed so accordion/expand + // animations have time to settle. useEffect(() => { if (isExactActive && itemRef.current) { - // Use a small delay to ensure the DOM is fully settled after navigation - // and any accordion/animation changes have completed const timeoutId = setTimeout(() => { itemRef.current?.scrollIntoView({ behavior: "smooth", @@ -68,104 +110,124 @@ export function SitemapItemNode({ if (item.sourcePath && onNavigate) { onNavigate(item.sourcePath); } else if (hasChildren) { - setIsOpen((isOpen) => !isOpen); + setIsOpen((prev) => !prev); } }; - const handleAddSubmit = (urlPath: string) => { - if (item.moduleFilePath && onAddPage) { - onAddPage(item.moduleFilePath, urlPath); + const handleAddSubmit = (moduleFilePath: ModuleFilePath, urlPath: string) => { + if (onAddPage) { + onAddPage(moduleFilePath, urlPath); setAddPopoverOpen(false); } }; + const rowRoute: AvailableRoute | null = useMemo(() => { + if (!item.canAddChild || !item.moduleFilePath || !item.routePattern) { + return null; + } + return { + moduleFilePath: item.moduleFilePath, + routePattern: item.routePattern, + patternString: routePatternToString(item.routePattern), + existingKeys: existingUrls ?? item.existingKeys ?? [], + }; + }, [ + item.canAddChild, + item.moduleFilePath, + item.routePattern, + item.existingKeys, + existingUrls, + ]); + + // Indent the row using a fixed left padding scaled by depth. + const indent = depth * 12 + 8; + return (
setShowActions(true)} onMouseLeave={() => setShowActions(false)} > - {hasChildren && ( + {hasChildren ? ( + ) : ( + )} + - {item.canAddChild && - item.routePattern && - (showActions || addPopoverOpen) && ( - - - - - + + + + + setAddPopoverOpen(false)} + /> + + + )}
{hasChildren && ( @@ -178,7 +240,9 @@ export function SitemapItemNode({ currentPath={currentPath} onNavigate={onNavigate} onAddPage={onAddPage} + existingUrls={existingUrls} depth={depth + 1} + parentPath={displayUrl === "/" ? "" : displayUrl} portalContainer={portalContainer} /> ))} @@ -189,129 +253,48 @@ export function SitemapItemNode({ ); } -/** - * Simplified route form for adding new pages. - * This can work standalone without providers for Storybook. - */ -function AddRouteForm({ - routePattern, - existingKeys, - onSubmit, - onCancel, +function DynamicSegmentPill({ + part, }: { - routePattern: RoutePattern[]; - existingKeys: string[]; - onSubmit: (urlPath: string) => void; - onCancel: () => void; + part: Extract< + RoutePattern, + { type: "string-param" } | { type: "array-param" } + >; }) { - const [params, setParams] = useState>({}); - const [errors, setErrors] = useState>({}); - - const fullPath = useMemo(() => { - return ( - "/" + - routePattern - .map((part) => { - if (part.type === "string-param" || part.type === "array-param") { - return params[part.paramName] || ""; - } - return part.name; - }) - .join("/") - ); - }, [routePattern, params]); - - const isComplete = useMemo(() => { - return routePattern.every((part) => { - if (part.type === "string-param" || part.type === "array-param") { - return !!params[part.paramName] && !errors[part.paramName]; - } - return true; - }); - }, [routePattern, params, errors]); - - const alreadyExists = existingKeys.includes(fullPath); - const disabled = !isComplete || alreadyExists; - + const label = + part.type === "array-param" ? `...${part.paramName}` : part.paramName; return ( -
{ - e.preventDefault(); - onSubmit(fullPath); - }} + -
- Add new page -
-
- {routePattern.map((part, i) => ( - - {part.type === "string-param" || part.type === "array-param" ? ( - - / - - p.type !== "literal") - } - className={cn( - "p-1 bg-bg-secondary border border-border-primary rounded max-w-[12ch] text-fg-primary", - "focus:outline-none focus:ring-1 focus:ring-border-focus", - { - "border-fg-error": errors[part.paramName], - }, - )} - placeholder={part.paramName} - value={params[part.paramName] || ""} - onChange={(e) => { - const value = e.target.value; - setParams({ ...params, [part.paramName]: value }); + [{label}] + + ); +} - // Validate: no special characters - const compareValue = - part.type === "string-param" - ? value - : value.replace(/\//g, ""); - if ( - value && - encodeURIComponent(compareValue) !== compareValue - ) { - setErrors({ - ...errors, - [part.paramName]: "Invalid characters", - }); - } else { - setErrors({ ...errors, [part.paramName]: undefined }); - } - }} - /> - {errors[part.paramName] && ( - - {errors[part.paramName]} - - )} - - - ) : ( - /{part.name} - )} - - ))} -
- {alreadyExists && ( -

- A page with this path already exists -

- )} -
- - -
- +/** + * Stringify a parsed route pattern back into a human-readable form like + * "/blogs/[blog]". Used as a display label and as part of the route's unique + * key. + */ +export function routePatternToString(pattern: RoutePattern[]): string { + if (pattern.length === 0) return "/"; + return ( + "/" + + pattern + .map((part) => { + if (part.type === "literal") return part.name; + if (part.type === "string-param") return `[${part.paramName}]`; + return `[...${part.paramName}]`; + }) + .join("/") ); } diff --git a/packages/ui/spa/components/NavMenu/SitemapSection.tsx b/packages/ui/spa/components/NavMenu/SitemapSection.tsx index c7a6617e1..d1d1f1ca8 100644 --- a/packages/ui/spa/components/NavMenu/SitemapSection.tsx +++ b/packages/ui/spa/components/NavMenu/SitemapSection.tsx @@ -1,14 +1,21 @@ -import { Globe } from "lucide-react"; +import React, { useMemo, useState } from "react"; +import { Globe, Plus } from "lucide-react"; +import { ModuleFilePath } from "@valbuild/core"; import { cn } from "../designSystem/cn"; import { SitemapItem } from "./types"; -import { SitemapItemNode } from "./SitemapItem"; +import { SitemapItemNode, routePatternToString } from "./SitemapItem"; import { AccordionContent, AccordionItem, AccordionTrigger, } from "../designSystem/accordion"; -import React from "react"; import { ScrollArea, ScrollBar } from "../designSystem/scroll-area"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "../designSystem/popover"; +import { AvailableRoute, NewPageForm } from "./NewPageForm"; export type SitemapSectionProps = { /** Sitemap data */ @@ -33,20 +40,85 @@ export function SitemapSection({ maxHeight = "100%", portalContainer, }: SitemapSectionProps) { + const [newPageOpen, setNewPageOpen] = useState(false); + + // All available routes the user can create pages under, plus the full set of + // existing URLs so the form can warn on duplicates regardless of which + // route is selected. + const { routes, existingUrls } = useMemo( + () => collectSitemapRoutes(sitemap), + [sitemap], + ); + + const handleNewPageSubmit = ( + moduleFilePath: ModuleFilePath, + urlPath: string, + ) => { + if (onAddPage) { + onAddPage(moduleFilePath, urlPath); + setNewPageOpen(false); + } + }; + return ( - + +
+ + Pages +
+
+ + {routes.length > 0 && ( +
+ + + + + + ({ + ...r, + existingKeys: existingUrls, + }))} + onSubmit={handleNewPageSubmit} + onCancel={() => setNewPageOpen(false)} + /> + + +
)} - > -
- - Pages -
-
+
+ {sitemap.sourcePath ? ( @@ -56,6 +128,7 @@ export function SitemapSection({ currentPath={currentPath} onNavigate={onNavigate} onAddPage={onAddPage} + existingUrls={existingUrls} portalContainer={portalContainer} /> ) : ( @@ -67,6 +140,7 @@ export function SitemapSection({ currentPath={currentPath} onNavigate={onNavigate} onAddPage={onAddPage} + existingUrls={existingUrls} portalContainer={portalContainer} /> ))} @@ -78,3 +152,48 @@ export function SitemapSection({ ); } + +/** + * Walk the sitemap collecting every unique route that accepts new pages, plus + * the flat list of every URL currently in the tree (used for duplicate + * validation in the top-level "New page" form). + */ +function collectSitemapRoutes(sitemap: SitemapItem): { + routes: AvailableRoute[]; + existingUrls: string[]; +} { + const routes = new Map(); + const urls: string[] = []; + + function walk(item: SitemapItem, parentPath: string) { + const displayUrl = + item.name === "/" || item.name === "" + ? "/" + : `${parentPath}/${item.name}`; + + if (item.sourcePath || item.children.length === 0) { + urls.push(displayUrl); + } + + if (item.canAddChild && item.moduleFilePath && item.routePattern) { + const patternString = routePatternToString(item.routePattern); + const key = `${item.moduleFilePath}::${patternString}`; + if (!routes.has(key)) { + routes.set(key, { + moduleFilePath: item.moduleFilePath, + routePattern: item.routePattern, + patternString, + existingKeys: [], + }); + } + } + + for (const child of item.children) { + walk(child, displayUrl === "/" ? "" : displayUrl); + } + } + + walk(sitemap, ""); + + return { routes: Array.from(routes.values()), existingUrls: urls }; +} diff --git a/packages/ui/spa/components/NavMenu/index.ts b/packages/ui/spa/components/NavMenu/index.ts index e9e8613fc..84883e50d 100644 --- a/packages/ui/spa/components/NavMenu/index.ts +++ b/packages/ui/spa/components/NavMenu/index.ts @@ -6,5 +6,6 @@ export * from "./ExplorerSection"; export * from "./ExternalButton"; export * from "./SitemapItem"; export * from "./ExplorerItem"; +export * from "./NewPageForm"; export * from "./useNavMenuData"; export * from "./mockData"; diff --git a/packages/ui/spa/components/NavMenu/mockData.ts b/packages/ui/spa/components/NavMenu/mockData.ts index 755bbbcaa..fa68c2a87 100644 --- a/packages/ui/spa/components/NavMenu/mockData.ts +++ b/packages/ui/spa/components/NavMenu/mockData.ts @@ -15,6 +15,23 @@ const blogsRoutePattern: RoutePattern[] = [ { type: "string-param", paramName: "blog", optional: false }, ]; +/** + * Mock route pattern for /docs/[...slug] — a catch-all route. + */ +const docsRoutePattern: RoutePattern[] = [ + { type: "literal", name: "docs" }, + { type: "array-param", paramName: "slug", optional: false }, +]; + +/** + * Mock route pattern for /shop/[category]/[product] — multi dynamic segment. + */ +const shopRoutePattern: RoutePattern[] = [ + { type: "literal", name: "shop" }, + { type: "string-param", paramName: "category", optional: false }, + { type: "string-param", paramName: "product", optional: false }, +]; + /** * Mock sitemap data for stories. */ @@ -78,6 +95,59 @@ export const mockSitemap: SitemapItem = { }, ], }, + { + name: "docs", + urlPath: "/docs", + canAddChild: true, + moduleFilePath: "/app/docs/[...slug]/page.val.ts" as ModuleFilePath, + routePattern: docsRoutePattern, + existingKeys: ["/guides/intro", "/api/auth"], + children: [ + { + name: "guides/intro", + urlPath: "/docs/guides/intro", + sourcePath: + '/app/docs/[...slug]/page.val.ts?p="/docs/guides/intro"' as SourcePath, + children: [], + }, + { + name: "api/auth", + urlPath: "/docs/api/auth", + sourcePath: + '/app/docs/[...slug]/page.val.ts?p="/docs/api/auth"' as SourcePath, + children: [], + }, + ], + }, + { + name: "shop", + urlPath: "/shop", + canAddChild: true, + moduleFilePath: + "/app/shop/[category]/[product]/page.val.ts" as ModuleFilePath, + routePattern: shopRoutePattern, + existingKeys: ["/electronics", "/clothing"], + children: [ + { + name: "electronics", + urlPath: "/shop/electronics", + canAddChild: true, + moduleFilePath: + "/app/shop/[category]/[product]/page.val.ts" as ModuleFilePath, + routePattern: shopRoutePattern, + existingKeys: ["/phone"], + children: [ + { + name: "phone", + urlPath: "/shop/electronics/phone", + sourcePath: + '/app/shop/[category]/[product]/page.val.ts?p="/shop/electronics/phone"' as SourcePath, + children: [], + }, + ], + }, + ], + }, ], }; diff --git a/packages/ui/spa/components/NavMenu/stories/NewPageForm.stories.tsx b/packages/ui/spa/components/NavMenu/stories/NewPageForm.stories.tsx new file mode 100644 index 000000000..a70f768ad --- /dev/null +++ b/packages/ui/spa/components/NavMenu/stories/NewPageForm.stories.tsx @@ -0,0 +1,110 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { ModuleFilePath } from "@valbuild/core"; +import { RoutePattern } from "@valbuild/shared/internal"; +import { NewPageForm, AvailableRoute } from "../NewPageForm"; + +const meta: Meta = { + title: "NavMenu/NewPageForm", + component: NewPageForm, + parameters: { + layout: "padded", + }, + tags: ["autodocs"], + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +const blogsPattern: RoutePattern[] = [ + { type: "literal", name: "blogs" }, + { type: "string-param", paramName: "blog", optional: false }, +]; + +const docsPattern: RoutePattern[] = [ + { type: "literal", name: "docs" }, + { type: "array-param", paramName: "slug", optional: false }, +]; + +const shopPattern: RoutePattern[] = [ + { type: "literal", name: "shop" }, + { type: "string-param", paramName: "category", optional: false }, + { type: "string-param", paramName: "product", optional: false }, +]; + +const blogsRoute: AvailableRoute = { + moduleFilePath: "/app/blogs/[blog]/page.val.ts" as ModuleFilePath, + routePattern: blogsPattern, + patternString: "/blogs/[blog]", + existingKeys: ["/blogs/blog-1", "/blogs/blog-2"], +}; + +const docsRoute: AvailableRoute = { + moduleFilePath: "/app/docs/[...slug]/page.val.ts" as ModuleFilePath, + routePattern: docsPattern, + patternString: "/docs/[...slug]", + existingKeys: ["/docs/guides/intro"], +}; + +const shopRoute: AvailableRoute = { + moduleFilePath: + "/app/shop/[category]/[product]/page.val.ts" as ModuleFilePath, + routePattern: shopPattern, + patternString: "/shop/[category]/[product]", + existingKeys: ["/shop/electronics/phone"], +}; + +export const SingleDynamicSegment: Story = { + render: () => ( + + console.log("Create:", { moduleFilePath, urlPath }) + } + onCancel={() => console.log("Cancel")} + /> + ), +}; + +export const CatchAllSegment: Story = { + render: () => ( + + console.log("Create:", { moduleFilePath, urlPath }) + } + onCancel={() => console.log("Cancel")} + /> + ), +}; + +export const MultipleSegments: Story = { + render: () => ( + + console.log("Create:", { moduleFilePath, urlPath }) + } + onCancel={() => console.log("Cancel")} + /> + ), +}; + +export const MultipleRoutes: Story = { + render: () => ( + + console.log("Create:", { moduleFilePath, urlPath }) + } + onCancel={() => console.log("Cancel")} + /> + ), + name: "Multiple routes (shows dropdown)", +}; diff --git a/packages/ui/spa/components/NavMenu/stories/SitemapSection.stories.tsx b/packages/ui/spa/components/NavMenu/stories/SitemapSection.stories.tsx index 4da3e3a29..d1194a50e 100644 --- a/packages/ui/spa/components/NavMenu/stories/SitemapSection.stories.tsx +++ b/packages/ui/spa/components/NavMenu/stories/SitemapSection.stories.tsx @@ -1,8 +1,11 @@ import type { Meta, StoryObj } from "@storybook/react"; import { useState } from "react"; +import { ModuleFilePath, SourcePath } from "@valbuild/core"; +import { RoutePattern } from "@valbuild/shared/internal"; import { SitemapSection } from "../SitemapSection"; import { mockSitemap, mockLargeSitemap } from "../mockData"; import { Accordion } from "../../designSystem/accordion"; +import { SitemapItem } from "../types"; const meta: Meta = { title: "NavMenu/SitemapSection", @@ -48,7 +51,7 @@ export const Default: Story = { } /> ), - name: "Default (with Add button)", + name: "Default", }; export const Collapsed: Story = { @@ -74,6 +77,110 @@ export const WithActivePath: Story = { ), }; +const catchAllRoutePattern: RoutePattern[] = [ + { type: "literal", name: "docs" }, + { type: "array-param", paramName: "slug", optional: false }, +]; + +const catchAllSitemap: SitemapItem = { + name: "/", + urlPath: "/", + children: [ + { + name: "docs", + urlPath: "/docs", + canAddChild: true, + moduleFilePath: "/app/docs/[...slug]/page.val.ts" as ModuleFilePath, + routePattern: catchAllRoutePattern, + existingKeys: ["/guides/intro", "/api/auth", "/guides/advanced/forms"], + children: [ + { + name: "guides/intro", + urlPath: "/docs/guides/intro", + sourcePath: + '/app/docs/[...slug]/page.val.ts?p="/docs/guides/intro"' as SourcePath, + children: [], + }, + { + name: "api/auth", + urlPath: "/docs/api/auth", + sourcePath: + '/app/docs/[...slug]/page.val.ts?p="/docs/api/auth"' as SourcePath, + children: [], + }, + ], + }, + ], +}; + +export const CatchAllRoute: Story = { + render: () => ( + console.log("Navigate to:", path)} + onAddPage={(moduleFilePath, urlPath) => + console.log("Add page:", { moduleFilePath, urlPath }) + } + /> + ), + name: "Catch-all route (open New page popover)", +}; + +const multiSegmentRoutePattern: RoutePattern[] = [ + { type: "literal", name: "shop" }, + { type: "string-param", paramName: "category", optional: false }, + { type: "string-param", paramName: "product", optional: false }, +]; + +const multiSegmentSitemap: SitemapItem = { + name: "/", + urlPath: "/", + children: [ + { + name: "shop", + urlPath: "/shop", + canAddChild: true, + moduleFilePath: + "/app/shop/[category]/[product]/page.val.ts" as ModuleFilePath, + routePattern: multiSegmentRoutePattern, + existingKeys: ["/electronics"], + children: [ + { + name: "electronics", + urlPath: "/shop/electronics", + canAddChild: true, + moduleFilePath: + "/app/shop/[category]/[product]/page.val.ts" as ModuleFilePath, + routePattern: multiSegmentRoutePattern, + existingKeys: ["/phone"], + children: [ + { + name: "phone", + urlPath: "/shop/electronics/phone", + sourcePath: + '/app/shop/[category]/[product]/page.val.ts?p="/shop/electronics/phone"' as SourcePath, + children: [], + }, + ], + }, + ], + }, + ], +}; + +export const MultiSegmentRoute: Story = { + render: () => ( + console.log("Navigate to:", path)} + onAddPage={(moduleFilePath, urlPath) => + console.log("Add page:", { moduleFilePath, urlPath }) + } + /> + ), + name: "Multi-segment route (/shop/[category]/[product])", +}; + export const LargeSitemap: Story = { render: () => ( Date: Sun, 17 May 2026 21:56:40 +0200 Subject: [PATCH 2/3] Update validation error look n feel --- packages/ui/.storybook/preview.js | 7 + .../ui/spa/components/NavMenu/ErrorBadge.tsx | 105 ++++++++++++++ .../spa/components/NavMenu/ExplorerItem.tsx | 137 ++++++++++-------- .../components/NavMenu/ExplorerSection.tsx | 11 ++ .../ui/spa/components/NavMenu/SitemapItem.tsx | 16 ++ .../spa/components/NavMenu/SitemapSection.tsx | 10 ++ .../components/NavMenu/errorAggregation.ts | 33 +++++ packages/ui/spa/components/NavMenu/index.ts | 2 + .../ui/spa/components/NavMenu/mockData.ts | 17 ++- .../stories/SitemapSection.stories.tsx | 14 ++ packages/ui/spa/components/NavMenu/types.ts | 23 ++- .../spa/components/NavMenu/useNavMenuData.ts | 111 ++++++++++++-- 12 files changed, 416 insertions(+), 70 deletions(-) create mode 100644 packages/ui/spa/components/NavMenu/ErrorBadge.tsx create mode 100644 packages/ui/spa/components/NavMenu/errorAggregation.ts diff --git a/packages/ui/.storybook/preview.js b/packages/ui/.storybook/preview.js index 29e7ac99f..24af334d0 100644 --- a/packages/ui/.storybook/preview.js +++ b/packages/ui/.storybook/preview.js @@ -1,4 +1,6 @@ +import React from "react"; import { withThemeByDataAttribute } from "@storybook/addon-themes"; +import { TooltipProvider } from "../spa/components/designSystem/tooltip"; import "tailwindcss/tailwind.css"; import "../spa/index.css"; @@ -14,6 +16,11 @@ export const decorators = [ defaultTheme: "dark", attributeName: "data-mode", }), + // Many components (e.g. the NavMenu's error badges) use Radix Tooltip + // which requires a TooltipProvider ancestor. The live app mounts one in + // `ValProvider`; Storybook needs its own. + (Story) => + React.createElement(TooltipProvider, null, React.createElement(Story)), ]; /** @type { import('@storybook/react').Preview } */ diff --git a/packages/ui/spa/components/NavMenu/ErrorBadge.tsx b/packages/ui/spa/components/NavMenu/ErrorBadge.tsx new file mode 100644 index 000000000..f8442b0ea --- /dev/null +++ b/packages/ui/spa/components/NavMenu/ErrorBadge.tsx @@ -0,0 +1,105 @@ +import { ReactNode } from "react"; +import { cn } from "../designSystem/cn"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "../designSystem/tooltip"; + +export type ErrorBadgeProps = { + /** Total error count to display in the badge. */ + count: number; + /** Number that resolve to this row itself (vs. descendants). Drives the + * tooltip wording. */ + ownCount: number; + /** First error message — shown verbatim when ownCount > 0. */ + firstMessage?: string; + /** Force the badge to render with smaller height (used in nested rows). */ + size?: "sm" | "md"; + /** Optional element wrapping the badge inside the tooltip trigger, for + * cases where the badge sits inside a button row. */ + asChild?: boolean; + /** Optional override className for the badge element. */ + className?: string; +}; + +/** + * The red pill used everywhere in the nav menu to indicate validation + * errors. Tooltip on hover/focus shows the first error message (for own + * errors) or an aggregate hint (for descendants). + * + * Relies on the TooltipProvider mounted in `ValProvider`. + */ +export function ErrorBadge({ + count, + ownCount, + firstMessage, + size = "sm", + className, +}: ErrorBadgeProps) { + if (count <= 0) return null; + const label = formatCount(count); + const tip = buildTooltip({ count, ownCount, firstMessage }); + return ( + + + + {label} + + + + {tip} + + + ); +} + +function formatCount(count: number): ReactNode { + if (count > 99) return "99+"; + return count; +} + +function buildTooltip({ + count, + ownCount, + firstMessage, +}: { + count: number; + ownCount: number; + firstMessage?: string; +}): ReactNode { + if (ownCount > 0 && firstMessage) { + if (ownCount === 1 && count === 1) { + return {firstMessage}; + } + return ( + + {firstMessage} + + {count === 1 + ? "1 error" + : ownCount === count + ? `${count} errors` + : `${count} errors total (${ownCount} here)`} + + + ); + } + // Aggregate-only: descendants carry the errors. + return {count === 1 ? "1 error below" : `${count} errors below`}; +} diff --git a/packages/ui/spa/components/NavMenu/ExplorerItem.tsx b/packages/ui/spa/components/NavMenu/ExplorerItem.tsx index 1e4612fc3..574a5a910 100644 --- a/packages/ui/spa/components/NavMenu/ExplorerItem.tsx +++ b/packages/ui/spa/components/NavMenu/ExplorerItem.tsx @@ -1,9 +1,10 @@ -import { ChevronRight, Folder, FileText, AlertCircle } from "lucide-react"; +import { ChevronRight } from "lucide-react"; import { useState, useMemo } from "react"; import { cn } from "../designSystem/cn"; import { AnimateHeight } from "../AnimateHeight"; import { ExplorerItem as ExplorerItemType } from "./types"; -import { prettifyFilename } from "../../utils/prettifyFilename"; +import { ErrorBadge } from "./ErrorBadge"; +import { totalExplorerErrorCount } from "./errorAggregation"; export type ExplorerItemProps = { item: ExplorerItemType; @@ -27,22 +28,16 @@ export function ExplorerItemNode({ const isActive = currentPath.startsWith(item.fullPath) && !item.isDirectory; const isActiveParent = currentPath.startsWith(item.fullPath) && item.isDirectory; + const isExactActive = currentPath === item.fullPath; - // Check if any children have errors (for collapsed indicator) - const hasDescendantError = useMemo(() => { - if (item.hasError) return true; - const checkChildren = (children: ExplorerItemType[]): boolean => { - return children.some( - (child) => child.hasError || checkChildren(child.children), - ); - }; - return checkChildren(item.children); - }, [item]); - - const showErrorIndicator = item.hasError || (!isOpen && hasDescendantError); + // Errors that resolve directly to this file (zero for directories), plus + // the recursive total used by the count badge. + const ownErrorCount = item.errors?.ownCount ?? (item.hasError ? 1 : 0); + const totalErrorCount = useMemo(() => totalExplorerErrorCount(item), [item]); + const hasOwnError = ownErrorCount > 0; + // Sort: directories first, then alphabetically. const sortedChildren = useMemo(() => { - // Sort: directories first, then alphabetically return [...item.children].sort((a, b) => { if (a.isDirectory && !b.isDirectory) return -1; if (!a.isDirectory && b.isDirectory) return 1; @@ -50,15 +45,11 @@ export function ExplorerItemNode({ }); }, [item.children]); - const handleClick = () => { - if (item.isDirectory) { - setIsOpen(!isOpen); - } else if (onNavigate) { - onNavigate(item.fullPath); - } - }; + // Strip val module suffixes — `.val.ts`/`.val.js` are noise on every row. + // Other extensions are kept so users can still tell different file types apart. + const displayName = useMemo(() => stripValExtension(item.name), [item.name]); - // Skip root node rendering + // Skip the synthetic "/" root row — just render its children at depth 0. if (item.name === "/" && depth === 0) { return ( <> @@ -75,57 +66,78 @@ export function ExplorerItemNode({ ); } + const handleClick = () => { + if (item.isDirectory) { + setIsOpen((prev) => !prev); + } else if (onNavigate) { + onNavigate(item.fullPath); + } + }; + + const indent = depth * 12 + 8; + return (
- + ) : ( + + )} +
+ - {showErrorIndicator && ( - + {totalErrorCount > 0 && ( + )} - + {hasChildren && ( @@ -145,3 +157,14 @@ export function ExplorerItemNode({ ); } + +/** + * Strip the val module extensions (`.val.ts`, `.val.js`) so the display reads + * as a clean module name. Other extensions are kept so users can still + * distinguish file types. + */ +function stripValExtension(name: string): string { + if (name.endsWith(".val.ts")) return name.slice(0, -".val.ts".length); + if (name.endsWith(".val.js")) return name.slice(0, -".val.js".length); + return name; +} diff --git a/packages/ui/spa/components/NavMenu/ExplorerSection.tsx b/packages/ui/spa/components/NavMenu/ExplorerSection.tsx index 291282d6b..339ee4fc8 100644 --- a/packages/ui/spa/components/NavMenu/ExplorerSection.tsx +++ b/packages/ui/spa/components/NavMenu/ExplorerSection.tsx @@ -1,4 +1,5 @@ import { PanelsTopLeft } from "lucide-react"; +import { useMemo } from "react"; import { cn } from "../designSystem/cn"; import { ExplorerItem } from "./types"; import { ExplorerItemNode } from "./ExplorerItem"; @@ -8,6 +9,8 @@ import { AccordionTrigger, } from "../designSystem/accordion"; import { ScrollArea } from "../designSystem/scroll-area"; +import { ErrorBadge } from "./ErrorBadge"; +import { totalExplorerErrorCount } from "./errorAggregation"; export type ExplorerSectionProps = { /** Explorer data */ @@ -26,6 +29,11 @@ export function ExplorerSection({ onNavigate, maxHeight = "100%", }: ExplorerSectionProps) { + const sectionErrorCount = useMemo( + () => totalExplorerErrorCount(explorer), + [explorer], + ); + return ( Explorer + {sectionErrorCount > 0 && ( + + )} diff --git a/packages/ui/spa/components/NavMenu/SitemapItem.tsx b/packages/ui/spa/components/NavMenu/SitemapItem.tsx index 55684848b..08f2fb6e7 100644 --- a/packages/ui/spa/components/NavMenu/SitemapItem.tsx +++ b/packages/ui/spa/components/NavMenu/SitemapItem.tsx @@ -11,6 +11,8 @@ import { PopoverTrigger, } from "../designSystem/popover"; import { AvailableRoute, NewPageForm } from "./NewPageForm"; +import { ErrorBadge } from "./ErrorBadge"; +import { totalSitemapErrorCount } from "./errorAggregation"; export type SitemapItemProps = { item: SitemapItemType; @@ -121,6 +123,12 @@ export function SitemapItemNode({ } }; + // Errors for this row + everything beneath it. The data layer attaches + // `errors.ownCount`; the descendant sum is recomputed each render but cached + // by item identity (stable across renders from `useNavMenuData`). + const ownErrorCount = item.errors?.ownCount ?? 0; + const totalErrorCount = useMemo(() => totalSitemapErrorCount(item), [item]); + const rowRoute: AvailableRoute | null = useMemo(() => { if (!item.canAddChild || !item.moduleFilePath || !item.routePattern) { return null; @@ -199,6 +207,14 @@ export function SitemapItemNode({ )} + {totalErrorCount > 0 && ( + + )} + {rowRoute && (showActions || addPopoverOpen) && ( diff --git a/packages/ui/spa/components/NavMenu/SitemapSection.tsx b/packages/ui/spa/components/NavMenu/SitemapSection.tsx index d1d1f1ca8..d0b3ea657 100644 --- a/packages/ui/spa/components/NavMenu/SitemapSection.tsx +++ b/packages/ui/spa/components/NavMenu/SitemapSection.tsx @@ -16,6 +16,8 @@ import { PopoverTrigger, } from "../designSystem/popover"; import { AvailableRoute, NewPageForm } from "./NewPageForm"; +import { ErrorBadge } from "./ErrorBadge"; +import { totalSitemapErrorCount } from "./errorAggregation"; export type SitemapSectionProps = { /** Sitemap data */ @@ -50,6 +52,11 @@ export function SitemapSection({ [sitemap], ); + const sectionErrorCount = useMemo( + () => totalSitemapErrorCount(sitemap), + [sitemap], + ); + const handleNewPageSubmit = ( moduleFilePath: ModuleFilePath, urlPath: string, @@ -73,6 +80,9 @@ export function SitemapSection({
Pages + {sectionErrorCount > 0 && ( + + )}
diff --git a/packages/ui/spa/components/NavMenu/errorAggregation.ts b/packages/ui/spa/components/NavMenu/errorAggregation.ts new file mode 100644 index 000000000..a68653ba4 --- /dev/null +++ b/packages/ui/spa/components/NavMenu/errorAggregation.ts @@ -0,0 +1,33 @@ +import { ExplorerItem, SitemapItem } from "./types"; + +/** + * Recursively sum the validation errors under a sitemap item, including its + * own. Used to drive the count badge on every row and the total in the + * "Pages" section header. + * + * The result is stable across renders as long as the input `item` reference + * is stable — `useNavMenuData` memoizes the tree, so calling this from a + * `useMemo([item])` is cheap in practice. + */ +export function totalSitemapErrorCount(item: SitemapItem): number { + let count = item.errors?.ownCount ?? 0; + for (const child of item.children) { + count += totalSitemapErrorCount(child); + } + return count; +} + +/** + * Recursively sum the validation errors under an explorer item. + * + * Directories don't contribute their own errors. Falls back to the legacy + * boolean `hasError` for callers that haven't moved to the new `errors` + * shape (mockData primarily). + */ +export function totalExplorerErrorCount(item: ExplorerItem): number { + let count = item.errors?.ownCount ?? (item.hasError ? 1 : 0); + for (const child of item.children) { + count += totalExplorerErrorCount(child); + } + return count; +} diff --git a/packages/ui/spa/components/NavMenu/index.ts b/packages/ui/spa/components/NavMenu/index.ts index 84883e50d..39f694d15 100644 --- a/packages/ui/spa/components/NavMenu/index.ts +++ b/packages/ui/spa/components/NavMenu/index.ts @@ -7,5 +7,7 @@ export * from "./ExternalButton"; export * from "./SitemapItem"; export * from "./ExplorerItem"; export * from "./NewPageForm"; +export * from "./ErrorBadge"; +export * from "./errorAggregation"; export * from "./useNavMenuData"; export * from "./mockData"; diff --git a/packages/ui/spa/components/NavMenu/mockData.ts b/packages/ui/spa/components/NavMenu/mockData.ts index fa68c2a87..dc95205d8 100644 --- a/packages/ui/spa/components/NavMenu/mockData.ts +++ b/packages/ui/spa/components/NavMenu/mockData.ts @@ -60,6 +60,10 @@ export const mockSitemap: SitemapItem = { urlPath: "/blogs/blog-2", sourcePath: '/app/blogs/[blog]/page.val.ts?p="/blogs/blog-2"' as SourcePath, + errors: { + ownCount: 2, + firstMessage: "Required field `title` is missing", + }, children: [], }, { @@ -175,7 +179,10 @@ export const mockExplorer: ExplorerItem = { fullPath: "/content/settings.val.ts", isDirectory: false, children: [], - hasError: true, + errors: { + ownCount: 3, + firstMessage: "Field `siteUrl` must be a valid URL", + }, }, ], }, @@ -281,7 +288,13 @@ export const mockLargeExplorer: ExplorerItem = { fullPath: `/content/article-${i + 1}.val.ts`, isDirectory: false, children: [], - hasError: i === 5, // One file has an error + errors: + i === 5 + ? { + ownCount: 1, + firstMessage: "Image alt text is required", + } + : undefined, })), }, { diff --git a/packages/ui/spa/components/NavMenu/stories/SitemapSection.stories.tsx b/packages/ui/spa/components/NavMenu/stories/SitemapSection.stories.tsx index d1194a50e..2c56117d3 100644 --- a/packages/ui/spa/components/NavMenu/stories/SitemapSection.stories.tsx +++ b/packages/ui/spa/components/NavMenu/stories/SitemapSection.stories.tsx @@ -181,6 +181,20 @@ export const MultiSegmentRoute: Story = { name: "Multi-segment route (/shop/[category]/[product])", }; +export const WithValidationErrors: Story = { + render: () => ( + console.log("Navigate to:", path)} + onAddPage={(moduleFilePath, urlPath) => + console.log("Add page:", { moduleFilePath, urlPath }) + } + /> + ), + name: "With validation errors (hover badges for tooltip)", +}; + export const LargeSitemap: Story = { render: () => ( ; /** * Transforms a SitemapNode (from shared/internal) to our SitemapItem type. + * + * Each row carries `errors.ownCount` and `errors.firstMessage` derived from + * validation errors keyed under this row's sourcePath. Descendant totals are + * computed at render time by recursing children. */ -function transformSitemapNode(node: SitemapNode | PageNode): SitemapItem { +function transformSitemapNode( + node: SitemapNode | PageNode, + errorsMap: ErrorsMap, +): SitemapItem { const canAddChild = !!node.pattern?.includes("["); const routePattern = canAddChild && node.pattern ? parseRoutePattern(node.pattern) : undefined; @@ -29,27 +40,45 @@ function transformSitemapNode(node: SitemapNode | PageNode): SitemapItem { ? node.children.map((child) => "/" + child.name) : undefined; + const sourcePath = node.sourcePath as SourcePath | undefined; + const errors = sourcePath + ? collectErrorsForSitemapEntry(errorsMap, sourcePath) + : undefined; + return { name: node.name, urlPath: node.pattern || "/", - sourcePath: node.sourcePath as SourcePath | undefined, + sourcePath, moduleFilePath: node.moduleFilePath as ModuleFilePath | undefined, canAddChild, routePattern, existingKeys, - children: node.children.map(transformSitemapNode), + errors, + children: node.children.map((child) => + transformSitemapNode(child, errorsMap), + ), }; } /** * Transforms a PathNode to our ExplorerItem type. + * + * Files attribute every error whose sourcePath starts with the file's + * fullPath. Directories don't get own errors — descendants are aggregated at + * render time. */ -function transformPathNode(node: PathNode): ExplorerItem { +function transformPathNode(node: PathNode, errorsMap: ErrorsMap): ExplorerItem { + const isDirectory = !!node.isDirectory; + const errors = + !isDirectory && node.fullPath + ? collectErrorsForModuleFilePath(errorsMap, node.fullPath) + : undefined; return { name: node.name, fullPath: node.fullPath, - isDirectory: !!node.isDirectory, - children: node.children.map(transformPathNode), + isDirectory, + errors, + children: node.children.map((child) => transformPathNode(child, errorsMap)), }; } @@ -66,12 +95,14 @@ export function useNavMenuData(): Remote { const shallowModules = useShallowModulesAtPaths(sitemapPaths, "record"); const srcFolder = useNextAppRouterSrcFolder(); + const validationErrors = useAllValidationErrors(); return useMemo((): Remote => { if (trees.status !== "success") { return trees; } + const errorsMap: ErrorsMap = validationErrors ?? {}; const data: NavMenuData = {}; // Transform sitemap if available @@ -96,7 +127,7 @@ export function useNavMenuData(): Remote { } } const sitemapTree = getNextAppRouterSitemapTree(srcFolder.data, paths); - data.sitemap = transformSitemapNode(sitemapTree); + data.sitemap = transformSitemapNode(sitemapTree, errorsMap); } else if ( srcFolder.status === "loading" || shallowModules.status === "loading" @@ -107,7 +138,7 @@ export function useNavMenuData(): Remote { // Transform explorer tree if available if (trees.data.root && trees.data.root.children.length > 0) { - data.explorer = transformPathNode(trees.data.root); + data.explorer = transformPathNode(trees.data.root, errorsMap); } // Add external module if available @@ -122,5 +153,65 @@ export function useNavMenuData(): Remote { status: "success", data, }; - }, [trees, sitemapPaths, srcFolder, shallowModules]); + }, [trees, sitemapPaths, srcFolder, shallowModules, validationErrors]); +} + +/** + * Collect the errors that resolve to a single sitemap entry. + * + * A sitemap entry has a sourcePath like + * `/app/blogs/[blog]/page.val.ts?p="/blogs/blog-1"` + * The errors map is keyed by the *path within the module*, e.g. + * `/app/blogs/[blog]/page.val.ts?p="/blogs/blog-1"."title"` + * + * An error belongs to a row if its key is the row's source path, or extends + * into it via a `.` (sub-property of the record entry). This deliberately + * avoids matching sibling record entries that share a prefix. + */ +function collectErrorsForSitemapEntry( + errorsMap: ErrorsMap, + sourcePath: SourcePath, +): NavItemErrors | undefined { + let ownCount = 0; + let firstMessage: string | undefined; + const exactPrefix = `${sourcePath}.`; + for (const keyString in errorsMap) { + const key = keyString as SourcePath; + if (key !== sourcePath && !keyString.startsWith(exactPrefix)) continue; + const list = errorsMap[key]; + if (!list || list.length === 0) continue; + ownCount += list.length; + if (!firstMessage) firstMessage = list[0]?.message; + } + return ownCount > 0 ? { ownCount, firstMessage } : undefined; +} + +/** + * Collect the errors attributable to a single explorer file. + * + * Files have a fullPath like `/content/authors.val.ts`. Errors are keyed by + * SourcePath which begins with the module file path, followed by `?p="..."` + * or `?` at the boundary. A startsWith check on `fullPath` is safe because + * sibling files have distinct names. + */ +function collectErrorsForModuleFilePath( + errorsMap: ErrorsMap, + fullPath: string, +): NavItemErrors | undefined { + let ownCount = 0; + let firstMessage: string | undefined; + for (const keyString in errorsMap) { + if (!keyString.startsWith(fullPath)) continue; + const next = keyString.charAt(fullPath.length); + // Only treat as belonging to this file if the source path either ends + // exactly at this file or continues with `?` (module path query + // separator). Bare prefixes like `/content/authors.val.ts` matching a + // hypothetical sibling `/content/authors.val.ts.backup` are excluded. + if (next !== "" && next !== "?") continue; + const list = errorsMap[keyString as SourcePath]; + if (!list || list.length === 0) continue; + ownCount += list.length; + if (!firstMessage) firstMessage = list[0]?.message; + } + return ownCount > 0 ? { ownCount, firstMessage } : undefined; } From 6ab38fa1643472a42b01cc7098575cd26fc1ef8d Mon Sep 17 00:00:00 2001 From: Fredrik Ekholdt Date: Sun, 17 May 2026 21:59:03 +0200 Subject: [PATCH 3/3] Changeset --- .changeset/six-laws-repair.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/six-laws-repair.md diff --git a/.changeset/six-laws-repair.md b/.changeset/six-laws-repair.md new file mode 100644 index 000000000..5e7207c08 --- /dev/null +++ b/.changeset/six-laws-repair.md @@ -0,0 +1,5 @@ +--- +"@valbuild/ui": patch +--- + +Updated look and feel of left navigation