diff --git a/examples/next/app/blogs/[blog]/page.val.ts b/examples/next/app/blogs/[blog]/page.val.ts index 23a2ee8a4..f9544a619 100644 --- a/examples/next/app/blogs/[blog]/page.val.ts +++ b/examples/next/app/blogs/[blog]/page.val.ts @@ -3,7 +3,7 @@ import authorsVal from "../../../content/authors.val"; import { linkSchema } from "../../../components/link.val"; const blogSchema = s.object({ - title: s.string(), + title: s.string().maxLength(3), content: s.richtext({ inline: { a: s.route(), diff --git a/packages/ui/spa/components/AnyField.tsx b/packages/ui/spa/components/AnyField.tsx index 33ed454d2..a8794478b 100644 --- a/packages/ui/spa/components/AnyField.tsx +++ b/packages/ui/spa/components/AnyField.tsx @@ -13,6 +13,9 @@ import { UnionField } from "./fields/UnionField"; import { DateField } from "./fields/DateField"; import { FieldSchemaError } from "./FieldSchemaError"; import { FileField } from "./fields/FileField"; +import { FieldValidationErrorCompact } from "./FieldValidationError"; + +export type ErrorDisplay = "default" | "compact" | "none"; export function AnyField({ path, @@ -22,6 +25,7 @@ export function AnyField({ compact, inline, hideUpload, + errorDisplay = "default", }: { path: SourcePath; schema: SerializedSchema; @@ -30,10 +34,12 @@ export function AnyField({ compact?: boolean; inline?: boolean; hideUpload?: boolean; + errorDisplay?: ErrorDisplay; }) { const leafProps = { readonly, compact }; + let leaf: React.ReactNode; if (schema.type === "string") { - return ( + leaf = ( ); } else if (schema.type === "number") { - return ; + leaf = ; } else if (schema.type === "boolean") { - return ; + leaf = ; } else if (schema.type === "image") { - return ( + leaf = ( ); } else if (schema.type === "array") { @@ -72,6 +79,7 @@ export function AnyField({ readonly={readonly} compact={compact} inline={inline} + errorDisplay={errorDisplay} /> ); } else if (schema.type === "record") { @@ -82,6 +90,7 @@ export function AnyField({ readonly={readonly} compact={compact} inline={inline} + errorDisplay={errorDisplay} /> ); } else if (schema.type === "union") { @@ -92,14 +101,15 @@ export function AnyField({ readonly={readonly} compact={compact} inline={inline} + errorDisplay={errorDisplay} /> ); } else if (schema.type === "keyOf") { - return ; + leaf = ; } else if (schema.type === "route") { - return ; + leaf = ; } else if (schema.type === "richtext") { - return ( + leaf = ( ); } else if (schema.type === "date") { - return ; + leaf = ; } else if (schema.type === "file") { - return ; + leaf = ; } else if (schema.type === "literal") { - return ( + leaf = ( ); } else { const exhaustiveCheck: never = schema; - return ( + leaf = ( ); } + + if (errorDisplay === "compact") { + return ( +
+
{leaf}
+
+ +
+
+ ); + } + return <>{leaf}; } diff --git a/packages/ui/spa/components/ComparePatchSets.tsx b/packages/ui/spa/components/ComparePatchSets.tsx index 84de01b60..cc4c27aa4 100644 --- a/packages/ui/spa/components/ComparePatchSets.tsx +++ b/packages/ui/spa/components/ComparePatchSets.tsx @@ -10,13 +10,10 @@ import { Fragment, useMemo, useState } from "react"; import { usePatchSetsWorker } from "../patchsets/usePatchSetsWorker"; import classNames from "classnames"; import { - AlertTriangle, ArrowRight, ChevronDown, Equal, - ExternalLink, Globe, - Loader2, Minus, Pencil, Plus, @@ -30,7 +27,6 @@ import { FieldSourceOverrideContext, useAllSources, useFilePatchIds, - useLoadingStatus, useSchemaAtPath, useSchemaWithResolvedPath, useSchemas, @@ -39,7 +35,6 @@ import { } from "./ValFieldProvider"; import { getFilenameFromRef, getRefParts } from "../utils/getFilenameFromRef"; import { useDeletePatches, Profile } from "./ValProvider"; -import { useAllValidationErrors } from "./ValErrorProvider"; import { useNavigation } from "./ValRouter"; import { useValPortal } from "./ValPortalProvider"; import { AnyField } from "./AnyField"; @@ -50,13 +45,9 @@ import { PopoverContent, PopoverTrigger, } from "./designSystem/popover"; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "./designSystem/tooltip"; import { getInitials } from "../utils/getInitials"; import { prettifyFilename } from "../utils/prettifyFilename"; +import { prettifyModulePath } from "../utils/prettifyText"; import { urlOf } from "@valbuild/shared/internal"; import { getNavPathFromAll } from "./getNavPath"; @@ -293,31 +284,22 @@ function ModuleGroup({ return (
- {!readonly && - modulePatchIds.length > 0 && - (isModuleEqual ? ( - - ) : ( - deletePatches(modulePatchIds)} - portalContainer={portalContainer} - ariaLabel="Discard all changes in this module" - /> - ))} + {!readonly && modulePatchIds.length > 0 && ( + deletePatches(modulePatchIds)} + confirmDescription="Discard all changes in this module? This cannot be undone." + unchangedAriaLabel="Discard all unchanged in this module" + confirmAriaLabel="Discard all changes in this module" + portalContainer={portalContainer} + /> + )} {Object.keys(modulePatchesByAuthorIds).length > 0 && ( )} - + setIsCollapsed((v) => !v)} + openLabel="Collapse module" + closedLabel="Expand module" + />
{!isCollapsed && ( @@ -539,7 +517,7 @@ function ChangeRow({ return (
- {!readonly && - (isEqual ? ( - - ) : ( - - ))} + {!readonly && ( + + )} - +
); @@ -695,7 +661,7 @@ function ChangeTargetLabel({ const navPath = getNavPathFromAll(sourcePath, allSources, schemasData); const target = navPath ?? sourcePath; navigate(target, { - scrollToId: target !== sourcePath ? sourcePath : undefined, + scrollToPath: target !== sourcePath ? sourcePath : undefined, }); }; @@ -724,7 +690,6 @@ function ChangeTargetLabel({ }); return ( - @@ -735,7 +700,7 @@ function ChangeTargetLabel({ className="shrink-0 text-fg-tertiary hover:text-fg-primary transition-colors" title={`Preview ${segment}`} > - + ); @@ -747,83 +712,6 @@ function ChangeTargetLabel({ ); } -function prettifyModulePath(modulePath: string): string { - // Show "home / title" instead of '"home"."title"'. - if (!modulePath) return modulePath; - // splitModulePath accepts the same string the engine uses internally; it's - // a branded type at the boundary but the existing helpers are tolerant. - const segments = Internal.splitModulePath( - modulePath as Parameters[0], - ); - return segments.join(" / "); -} - -function ValidationErrorLink({ sourcePath }: { sourcePath: SourcePath }) { - const { navigate } = useNavigation(); - const schemas = useSchemas(); - const allSources = useAllSources(); - const validationErrors = useAllValidationErrors(); - const loadingStatus = useLoadingStatus(); - const isLoading = loadingStatus === "loading"; - const messages: string[] = []; - if (validationErrors) { - for (const errorPath in validationErrors) { - if (errorPath.startsWith(sourcePath)) { - for (const err of validationErrors[errorPath as SourcePath] ?? []) { - messages.push(err.message); - } - } - } - } - if (messages.length === 0) return
; - - return ( - - - - - - {messages.length === 1 ? ( -

{messages[0]}

- ) : ( -
    - {messages.map((msg, i) => ( -
  • {msg}
  • - ))} -
- )} -
-
- ); -} - function ChangeTypeLabel({ changeType, isEqual, @@ -1037,16 +925,12 @@ function MediaEntryAlt({ return (
-
-
- -
- {showValidation && } -
+
); } @@ -1168,11 +1052,6 @@ function MediaEntryDiff({ const isImage = mediaType === "images"; const afterSource = useSourceAtPath(sourcePath); - const [moduleFilePath] = useMemo( - () => Internal.splitModuleFilePathAndModulePath(sourcePath), - [sourcePath], - ); - const beforeModuleSource = useServerSourceAtPath(moduleFilePath); const beforeSource = useServerSourceAtPath(sourcePath); const afterMetadata = @@ -1188,12 +1067,6 @@ function MediaEntryDiff({ const beforeHotspot = extractHotspot(beforeMetadata); const imageUrl = isImage ? refToUrl(fileRef, filePatchIds) : null; - - const beforeOverride = - beforeModuleSource.status === "success" - ? { moduleFilePath, moduleSource: beforeModuleSource.data } - : null; - const filename = getFilenameFromRef(fileRef); if (!isExpanded && changeType !== "field-change") return null; @@ -1202,19 +1075,16 @@ function MediaEntryDiff({ return (
-
- {isImage && ( - - )} -
- -
-
- +
); @@ -1225,24 +1095,17 @@ function MediaEntryDiff({ return (
-
- {isImage && ( - - )} -
- -
-
- +
); @@ -1252,26 +1115,22 @@ function MediaEntryDiff({ if (!isExpanded) return null; const originalUrl = isImage ? staticFileUrl(fileRef) : null; return ( - +
-
- {isImage && ( - - )} -
- -
-
- +
-
+ ); } @@ -1285,24 +1144,17 @@ function MediaEntryDiff({ return (
-
- {isImage && ( - - )} -
- -
-
- +
); @@ -1319,32 +1171,21 @@ function MediaEntryDiff({ /> )}
-
-
-
- Before -
- + - -
-
- -
-
-
- After -
+ + } + after={ -
-
+ } + />
@@ -1353,25 +1194,15 @@ function MediaEntryDiff({ } function RemovedSideContent({ sourcePath }: { sourcePath: SourcePath }) { - const [moduleFilePath] = useMemo( - () => Internal.splitModuleFilePathAndModulePath(sourcePath), - [sourcePath], - ); - const serverModuleSource = useServerSourceAtPath(moduleFilePath); - const beforeOverride = - serverModuleSource.status === "success" - ? { moduleFilePath, moduleSource: serverModuleSource.data } - : null; - return ( - + - + ); } @@ -1387,15 +1218,10 @@ function FieldChangeDiff({ isEqual: boolean; }) { const schemaWithPath = useSchemaWithResolvedPath(sourcePath); - const [moduleFilePath] = useMemo( - () => Internal.splitModuleFilePathAndModulePath(sourcePath), - [sourcePath], - ); const effectivePath = schemaWithPath.status === "success" ? schemaWithPath.resolvedPath : sourcePath; - const beforeModuleSource = useServerSourceAtPath(moduleFilePath); const beforeSource = useServerSourceAtPath(effectivePath); if (schemaWithPath.status !== "success") return null; @@ -1404,17 +1230,26 @@ function FieldChangeDiff({ const beforeAvailable = beforeSource.status === "success"; const beforeIsNull = beforeAvailable && beforeSource.data === null; - const beforeOverride = - beforeModuleSource.status === "success" - ? { moduleFilePath, moduleSource: beforeModuleSource.data } - : null; - if (!isExpanded) return null; if (isEqual) { return ( -
-
+ + + + } + after={ -
-
- -
-
+ } + /> ); } if (beforeIsNull || !beforeAvailable) { return ( -
-
- - - -
-
- -
+
+ + +
); } - { - /* Side-by-side before/after. - The coloured border lives on the left column (not an outer wrapper) - so the grid is fully symmetric and the arrow sits exactly midway - between the two content areas. - Both columns reserve a trailing size-6 slot (spacer left, - ValidationErrorLink right) so AnyField gets identical width. */ - } return ( -
-
-
- - - -
-
-
-
-
-
- -
-
-
+ -
-
- -
-
-
+ + } + after={ + + } + /> ); } @@ -1559,36 +1367,31 @@ function SingleSideContentInner({ const schema = schemaAtPath.data; return ( -
-
- - {diffStyle === "removed" ? ( -
- -
- ) : ( +
+ + {diffStyle === "removed" ? ( +
- )} - -
- {diffStyle === "added" && ( -
- -
- )} +
+ ) : ( + + )} +
); } @@ -1691,6 +1494,201 @@ function SummaryAvatar({ ); } +// #region CollapseToggle + +function CollapseToggle({ + isOpen, + onToggle, + openLabel, + closedLabel, +}: { + isOpen: boolean; + onToggle: () => void; + openLabel: string; + closedLabel: string; +}) { + return ( + + ); +} + +// #region DiscardControl + +function DiscardControl({ + isEqual, + onDiscard, + confirmDescription, + unchangedAriaLabel, + confirmAriaLabel, + portalContainer, +}: { + isEqual: boolean; + onDiscard: () => void; + confirmDescription: string; + unchangedAriaLabel: string; + confirmAriaLabel: string; + portalContainer: HTMLElement | null; +}) { + if (isEqual) { + return ( + + ); + } + return ( + + ); +} + +// #region BeforeSourceOverride + +function BeforeSourceOverride({ + sourcePath, + children, +}: { + sourcePath: SourcePath; + children: React.ReactNode; +}) { + const [moduleFilePath] = useMemo( + () => Internal.splitModuleFilePathAndModulePath(sourcePath), + [sourcePath], + ); + const beforeModuleSource = useServerSourceAtPath(moduleFilePath); + const beforeOverride = + beforeModuleSource.status === "success" + ? { moduleFilePath, moduleSource: beforeModuleSource.data } + : null; + return ( + + {children} + + ); +} + +// #region BeforeAfterLayout + +function BeforeAfterLayout({ + variant, + before, + after, +}: { + variant: "equal" | "changed" | "media"; + before: React.ReactNode; + after: React.ReactNode; +}) { + if (variant === "media") { + return ( +
+
+
+ Before +
+ {before} +
+
+ +
+
+
After
+ {after} +
+
+ ); + } + const borderColor = + variant === "equal" ? "border-border-secondary" : "border-fg-brand-primary"; + const MiddleIcon = variant === "equal" ? Equal : ArrowRight; + return ( +
+
+ {before} +
+
+ +
+
{after}
+
+ ); +} + +// #region MediaEntryCard + +function MediaEntryCard({ + isImage, + url, + filename, + hotspot, + diffStyle, + metadata, + sourcePath, + altReadonly, + showValidation, +}: { + isImage: boolean; + url: string | null; + filename: string; + hotspot?: { x: number; y: number }; + diffStyle?: "added" | "removed"; + metadata: Record | null; + sourcePath: SourcePath; + altReadonly: boolean; + showValidation?: boolean; +}) { + return ( + <> +
+ {isImage && ( + + )} +
+ +
+
+ + + ); +} + // #region DiscardConfirmPopover function DiscardConfirmPopover({ diff --git a/packages/ui/spa/components/ContentArea.tsx b/packages/ui/spa/components/ContentArea.tsx index 0d56a9ed5..eba9023cc 100644 --- a/packages/ui/spa/components/ContentArea.tsx +++ b/packages/ui/spa/components/ContentArea.tsx @@ -16,6 +16,7 @@ import { CompareSummaryStrip, flattenChanges, } from "./ComparePatchSets"; +import { ValidationErrorsView } from "./ValidationErrors"; import { usePatchSetsWorker } from "../patchsets/usePatchSetsWorker"; import { useValPortal } from "./ValPortalProvider"; import { useLayout } from "./Layout"; @@ -39,7 +40,8 @@ import { export function ContentArea() { const connectionStatus = useConnectionStatus(); const globalError = useGlobalError(); - const { isCompareView } = useNavigation(); + const { isCompareView, isErrorsView } = useNavigation(); + const isWideView = isCompareView || isErrorsView; return (
@@ -78,7 +80,7 @@ export function ContentArea() { )}
) : isCompareView ? ( + ) : isErrorsView ? ( + ) : ( )} diff --git a/packages/ui/spa/components/Field.tsx b/packages/ui/spa/components/Field.tsx index d4986ff7e..27d07c98d 100644 --- a/packages/ui/spa/components/Field.tsx +++ b/packages/ui/spa/components/Field.tsx @@ -1,5 +1,6 @@ import { Json, SerializedSchema, SourcePath } from "@valbuild/core"; import { Label } from "./Label"; +import { fromCamelToTitleCase } from "../utils/prettifyText"; import classNames from "classnames"; import { ChevronDown, ChevronsDown } from "lucide-react"; import { Checkbox } from "./designSystem/checkbox"; @@ -14,8 +15,10 @@ import { } from "./designSystem/accordion"; import { FieldValidationError } from "./FieldValidationError"; import { FieldPatchAuthorsSection } from "./FieldPatchAuthorsSection"; -import { ShallowSource } from "./ValFieldProvider"; +import { ShallowSource, useAllSources, useSchemas } from "./ValFieldProvider"; import { useFieldState } from "./useFieldState"; +import { useNavigation } from "./ValRouter"; +import { getNavPathFromAll } from "./getNavPath"; export function Field({ label, @@ -29,6 +32,7 @@ export function Field({ source: sourceProp, schema: schemaProp, initialExpanded, + errorDisplay = "default", }: { label?: string | React.ReactNode; children: React.ReactNode; @@ -41,6 +45,7 @@ export function Field({ source?: Json | null; schema?: SerializedSchema; initialExpanded?: boolean; + errorDisplay?: "default" | "compact" | "none"; }) { if ((sourceProp !== undefined) !== (schemaProp !== undefined)) { throw new Error( @@ -67,14 +72,29 @@ export function Field({ isNullable, } = useFieldState(path, type, overrides, initialExpanded); const effectiveReadonly = readonly || hasOverrides; + const { navigate } = useNavigation(); + const schemas = useSchemas(); + const allSources = useAllSources(); + const handleLabelNavigate = () => { + const schemasData = schemas.status === "success" ? schemas.data : undefined; + const navPath = getNavPathFromAll(path, allSources, schemasData); + const target = navPath ?? path; + navigate(target, { + scrollToPath: target !== path ? path : undefined, + }); + }; + const labelClickable = errorDisplay === "compact"; return (
0, + "border-bg-warning-secondary": + !hasOverrides && + errorDisplay === "default" && + validationErrors.length > 0, })} >
)} - {typeof label === "string" && } + {typeof label === "string" && + (labelClickable ? ( + + ) : ( + + ))} {label && typeof label !== "string" && label}
)} - {!hasOverrides && validationErrors.length > 0 && ( -
- -
- )} + {!hasOverrides && + errorDisplay === "default" && + validationErrors.length > 0 && ( +
+ +
+ )}
); } diff --git a/packages/ui/spa/components/FieldErrorList.tsx b/packages/ui/spa/components/FieldErrorList.tsx new file mode 100644 index 000000000..c34ef7208 --- /dev/null +++ b/packages/ui/spa/components/FieldErrorList.tsx @@ -0,0 +1,52 @@ +import { CircleAlert } from "lucide-react"; +import { ValidationError } from "@valbuild/core"; +import classNames from "classnames"; + +/** + * Shared error-list rendering for fields. Used by: + * - `FieldValidationError` (inline below an editor field) + * - `ValidationErrors` (the dedicated `/val/errors` page) + * + * The look is deliberately the amber/warning palette rather than error-red: + * it makes the messages visible at a glance without reading as "the system is + * broken". A 2px amber rail on the left groups all messages for one field + * together; each message is prefixed with a small `CircleAlert` icon. + * + * Render nothing if there are no errors. Callers don't need to gate. + */ +export function FieldErrorList({ + validationErrors, + ariaLabel = "Validation errors for this field", + className, +}: { + validationErrors: ValidationError[]; + ariaLabel?: string; + className?: string; +}) { + if (validationErrors.length === 0) { + return null; + } + return ( +
+ +
    + {validationErrors.map((err, i) => ( +
  • + + {err.message} +
  • + ))} +
+
+ ); +} diff --git a/packages/ui/spa/components/FieldPatchAuthors.tsx b/packages/ui/spa/components/FieldPatchAuthors.tsx index cc04dca24..cfc073291 100644 --- a/packages/ui/spa/components/FieldPatchAuthors.tsx +++ b/packages/ui/spa/components/FieldPatchAuthors.tsx @@ -276,7 +276,7 @@ export function FieldPatchAuthors({ const onNavigateToCompare = navigable && sourcePath - ? () => navigate("/val/compare", { scrollToId: `compare-${sourcePath}` }) + ? () => navigate("/val/compare", { scrollToPath: sourcePath }) : undefined; if (!onNavigateToCompare) { diff --git a/packages/ui/spa/components/FieldValidationError.tsx b/packages/ui/spa/components/FieldValidationError.tsx index d79c38038..c3353fa0f 100644 --- a/packages/ui/spa/components/FieldValidationError.tsx +++ b/packages/ui/spa/components/FieldValidationError.tsx @@ -1,173 +1,100 @@ -import { ChevronDown, TriangleAlert } from "lucide-react"; +import { SourcePath, ValidationError } from "@valbuild/core"; +import classNames from "classnames"; +import { AlertTriangle, Loader2 } from "lucide-react"; +import { FieldErrorList } from "./FieldErrorList"; import { - Accordion, - AccordionContent, - AccordionItem, - AccordionTrigger, -} from "./designSystem/accordion"; -import { cn } from "./designSystem/cn"; -import { ValidationError } from "@valbuild/core"; -import { useState, useRef, useEffect } from "react"; + Tooltip, + TooltipContent, + TooltipTrigger, +} from "./designSystem/tooltip"; +import { + useAllSources, + useLoadingStatus, + useSchemas, +} from "./ValFieldProvider"; +import { useAllValidationErrors } from "./ValErrorProvider"; +import { useNavigation } from "./ValRouter"; +import { getNavPathFromAll } from "./getNavPath"; +/** + * Inline error display rendered below a field in the editor. + * + * The visual style is owned by `FieldErrorList` and is shared with the + * `/val/errors` page so the same field reads identically in both views. + */ export function FieldValidationError({ validationErrors, }: { validationErrors: ValidationError[]; }) { + if (validationErrors.length === 0) { + return null; + } return ( -
- {validationErrors.length > 1 && ( - - - -
-
- {validationErrors.length} validation error - {validationErrors.length > 1 ? "s" : ""} -
-
- -
-
-
- -
- {validationErrors.map((error, i) => ( -
- {error.message} -
- ))} -
-
-
-
- )} - - {validationErrors.length === 1 && validationErrors[0] && ( - - )} +
+
); } -function SingleValidationError({ - validationError, -}: { - validationError: ValidationError; -}) { - const [open, setOpen] = useState(false); - const [showChevron, setShowChevron] = useState(false); - const textRef = useRef(null); - const containerRef = useRef(null); - - useEffect(() => { - const checkTextOverflow = () => { - if (textRef.current) { - // Create a test element with the same content and styling but without line-clamp - const testElement = document.createElement("div"); - const computedStyle = window.getComputedStyle(textRef.current); - - // Copy relevant styles but remove line clamp - testElement.style.cssText = computedStyle.cssText; - testElement.style.position = "absolute"; - testElement.style.visibility = "hidden"; - testElement.style.left = "-9999px"; - testElement.style.width = computedStyle.width; - testElement.style.webkitLineClamp = "unset"; - testElement.style.overflow = "visible"; - testElement.style.display = "block"; - testElement.textContent = validationError.message; - - document.body.appendChild(testElement); - const fullHeight = testElement.getBoundingClientRect().height; - document.body.removeChild(testElement); - - // Get the current height of the clamped element - const clampedHeight = textRef.current.getBoundingClientRect().height; - - // Show chevron if the full text is taller than the clamped version - setShowChevron(fullHeight > clampedHeight + 1); // Adding 1px tolerance for rounding +export function FieldValidationErrorCompact({ path }: { path: SourcePath }) { + const { navigate } = useNavigation(); + const schemas = useSchemas(); + const allSources = useAllSources(); + const validationErrors = useAllValidationErrors(); + const loadingStatus = useLoadingStatus(); + const isLoading = loadingStatus === "loading"; + const messages: string[] = []; + if (validationErrors) { + for (const errorPath in validationErrors) { + if (errorPath.startsWith(path)) { + for (const err of validationErrors[errorPath as SourcePath] ?? []) { + messages.push(err.message); + } } - }; - - // Use a small delay to ensure DOM is ready and styles are applied - const timeoutId = setTimeout(checkTextOverflow, 50); - - window.addEventListener("resize", checkTextOverflow); - return () => { - clearTimeout(timeoutId); - window.removeEventListener("resize", checkTextOverflow); - }; - }, [validationError.message]); + } + } + if (messages.length === 0) return
; return ( -
-
- {validationError.message} -
- {!showChevron && ( -
- -
- )} - {showChevron && ( + + - )} -
+ + + {messages.length === 1 ? ( +

{messages[0]}

+ ) : ( +
    + {messages.map((msg, i) => ( +
  • {msg}
  • + ))} +
+ )} +
+ ); } diff --git a/packages/ui/spa/components/FileGallery/FilePropertiesModal.tsx b/packages/ui/spa/components/FileGallery/FilePropertiesModal.tsx index afdc2203d..90d217671 100644 --- a/packages/ui/spa/components/FileGallery/FilePropertiesModal.tsx +++ b/packages/ui/spa/components/FileGallery/FilePropertiesModal.tsx @@ -233,7 +233,7 @@ export function FilePropertiesModal({ type="button" onClick={() => { navigate("/val/compare", { - scrollToId: `compare-${file.sourcePath}`, + scrollToPath: file.sourcePath, }); }} className="inline-flex items-center gap-2 rounded-md bg-bg-secondary px-3 py-2 text-sm font-medium text-fg-primary transition-colors hover:bg-bg-tertiary" diff --git a/packages/ui/spa/components/InlineField.tsx b/packages/ui/spa/components/InlineField.tsx index 2bcea8fe9..54230c39d 100644 --- a/packages/ui/spa/components/InlineField.tsx +++ b/packages/ui/spa/components/InlineField.tsx @@ -63,7 +63,7 @@ export function InlineField({ return (
0, })} > diff --git a/packages/ui/spa/components/Module.tsx b/packages/ui/spa/components/Module.tsx index 6e255031e..f711a1224 100644 --- a/packages/ui/spa/components/Module.tsx +++ b/packages/ui/spa/components/Module.tsx @@ -158,7 +158,7 @@ export function Module({ )}
0, })} > @@ -207,7 +207,7 @@ export function Module({ )}
0, })} > diff --git a/packages/ui/spa/components/SortableList.tsx b/packages/ui/spa/components/SortableList.tsx index dcfea76cb..7713cf7d7 100644 --- a/packages/ui/spa/components/SortableList.tsx +++ b/packages/ui/spa/components/SortableList.tsx @@ -260,7 +260,7 @@ export function SortableItemRow({ {!render && schema?.item?.type === "string" && (
diff --git a/packages/ui/spa/components/ToolsMenu.tsx b/packages/ui/spa/components/ToolsMenu.tsx index ec306238a..009e9d93e 100644 --- a/packages/ui/spa/components/ToolsMenu.tsx +++ b/packages/ui/spa/components/ToolsMenu.tsx @@ -14,12 +14,15 @@ import { useValConfig, } from "./ValFieldProvider"; import { ScrollArea } from "./designSystem/scroll-area"; +import { PatchErrorsDisplay, TransientErrorsDisplay } from "./DraftChanges"; import { - PatchErrorsDisplay, - ValidationErrorsDisplay, - TransientErrorsDisplay, -} from "./DraftChanges"; -import { GitCompareArrows, Globe, Loader2, PanelsTopLeft } from "lucide-react"; + AlertTriangle, + CheckCircle2, + GitCompareArrows, + Globe, + Loader2, + PanelsTopLeft, +} from "lucide-react"; import { Button } from "./designSystem/button"; import { urlOf } from "@valbuild/shared/internal"; import { Fragment, useMemo, useRef, useState } from "react"; @@ -29,7 +32,7 @@ import type { AIChatHandle } from "./AIChat"; import { useAI } from "../hooks/useAI"; import { Internal, ModuleFilePath, SourcePath } from "@valbuild/core"; import { prettifyFilename } from "../utils/prettifyFilename"; -import { useNavigation } from "./ValRouter"; +import { useNavigation, VAL_ERRORS_ROUTE } from "./ValRouter"; import { PublishButton } from "./PublishButton"; import { Checkbox } from "./designSystem/checkbox"; import { @@ -46,17 +49,19 @@ export function ToolsMenu() { const mode = useValMode(); const config = useValConfig(); const isChatEnabled = config?.ai?.chat?.experimental?.enable === true; - const [errorModules, sumValidationErrors] = useMemo(() => { + const [errorModules, fieldsWithErrors, errorPaths] = useMemo(() => { const modulesWithErrors = new Set(); - let sumValidationErrors = 0; + const errorPaths: SourcePath[] = []; + let fieldsWithErrors = 0; for (const sourcePath in validationErrors) { const [moduleFilePath] = Internal.splitModuleFilePathAndModulePath( sourcePath as SourcePath, ); modulesWithErrors.add(moduleFilePath); - sumValidationErrors += 1; + errorPaths.push(sourcePath as SourcePath); + fieldsWithErrors += 1; } - return [Array.from(modulesWithErrors).sort(), sumValidationErrors]; + return [Array.from(modulesWithErrors).sort(), fieldsWithErrors, errorPaths]; }, [validationErrors]); const currentPatchIds = useCurrentPatchIds(); const committedPatchIds = useCommittedPatches(); @@ -96,14 +101,13 @@ export function ToolsMenu() {
{globalErrors && globalErrors.length > 0 && - globalErrors.length !== sumValidationErrors && ( + globalErrors.length !== fieldsWithErrors && (
Cannot {mode === "fs" ? "save" : "publish"} now. Found{" "} {globalErrors?.length} errors in all.{" "} - {globalErrors.length - sumValidationErrors} were - non-validation errors. A developer might need to fix these - issues. + {globalErrors.length - fieldsWithErrors} were non-validation + errors. A developer might need to fix these issues.
@@ -114,7 +118,7 @@ export function ToolsMenu() {
)} - {globalErrors?.length === sumValidationErrors && + {globalErrors?.length === fieldsWithErrors && errorModules.length > 0 && (
@@ -131,14 +135,14 @@ export function ToolsMenu() {
)} - - {loadingStatus !== "not-asked" && ( - - )} +
{(mode === "http" || mode === "fs") && isChatEnabled && ( @@ -210,6 +214,89 @@ function ShortenedErrorMessage({ error }: { error: string }) { ); } +function ValidationAndCompareRow({ + fieldsWithErrors, + errorPaths, + showCompare, + pendingChanges, + isLoading, +}: { + fieldsWithErrors: number; + errorPaths: SourcePath[]; + showCompare: boolean; + pendingChanges: number; + isLoading: boolean; +}) { + return ( +
+ + {showCompare && ( + + )} +
+ ); +} + +function ValidationStatusPill({ + fieldsWithErrors, + errorPaths, +}: { + fieldsWithErrors: number; + errorPaths: SourcePath[]; +}) { + const { navigate } = useNavigation(); + const pillBase = + "inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium whitespace-nowrap"; + if (fieldsWithErrors === 0) { + return ( + + + + + No errors + + + +

No validation errors

+
+
+ ); + } + const label = + fieldsWithErrors === 1 ? "1 field" : `${fieldsWithErrors} fields`; + return ( + + + + + +

Open validation errors

+
+
+ ); +} + function CompareButton({ pendingChanges, isLoading, @@ -219,22 +306,17 @@ function CompareButton({ }) { const { navigate } = useNavigation(); return ( -
- -
+ ); } diff --git a/packages/ui/spa/components/ValPath.tsx b/packages/ui/spa/components/ValPath.tsx index ed829b7eb..bbe91e867 100644 --- a/packages/ui/spa/components/ValPath.tsx +++ b/packages/ui/spa/components/ValPath.tsx @@ -129,7 +129,7 @@ export function ValPath({ if (navPath) { return { navPath, - scrollToId: sourcePath, + scrollToPath: sourcePath, }; } else { console.debug( @@ -154,7 +154,11 @@ export function ValPath({ void; currentSourcePath: SourcePath; isCompareView: boolean; + isErrorsView: boolean; + errorFields: SourcePath[]; }; const ValRouterContext = React.createContext( new Proxy( @@ -35,33 +43,32 @@ const ValRouterContext = React.createContext( const VAL_CONTENT_VIEW_ROUTE = "/val/~"; // TODO: make route configurable -function findFieldWrapper(element: HTMLElement): HTMLElement { - let el: HTMLElement | null = element.parentElement; - while (el) { - if (el.id === "val-content-area") break; - if ( - el.classList.contains("border") && - el.classList.contains("rounded-lg") - ) { - return el; - } - el = el.parentElement; +const STUDIO_PATH_ATTR = "data-val-studio-path"; + +function findStudioPathTarget( + root: ShadowRoot, + path: string, +): HTMLElement | null { + const candidates = Array.from( + root.querySelectorAll(`[${STUDIO_PATH_ATTR}]`), + ); + for (const el of candidates) { + if (el.getAttribute(STUDIO_PATH_ATTR) === path) return el; } - return element; + return null; } function doScroll(shadowRoot: ShadowRoot, element: HTMLElement) { - const target = findFieldWrapper(element); shadowRoot.getElementById("val-content-area")?.scrollTo({ - top: Math.max(0, target.offsetTop - 16), + top: Math.max(0, element.offsetTop - 16), behavior: "smooth", }); - target.classList.remove("val-scroll-highlight"); - void target.offsetWidth; - target.classList.add("val-scroll-highlight"); - target.addEventListener( + element.classList.remove("val-scroll-highlight"); + void element.offsetWidth; + element.classList.add("val-scroll-highlight"); + element.addEventListener( "animationend", - () => target.classList.remove("val-scroll-highlight"), + () => element.classList.remove("val-scroll-highlight"), { once: true }, ); } @@ -79,6 +86,8 @@ export function ValRouter({ const [ready, setReady] = useState(false); const [currentSourcePath, setSourcePath] = useState("" as SourcePath); const [isCompareView, setIsCompareView] = useState(false); + const [isErrorsView, setIsErrorsView] = useState(false); + const [errorFields, setErrorFields] = useState([]); const historyState = useRef([]); useEffect(() => { const listener = () => { @@ -87,11 +96,30 @@ export function ValRouter({ location.pathname === VAL_COMPARE_ROUTE + "/" ) { setIsCompareView(true); + setIsErrorsView(false); + setErrorFields([]); + setSourcePath("" as SourcePath); + setReady(true); + return; + } + if ( + location.pathname === VAL_ERRORS_ROUTE || + location.pathname === VAL_ERRORS_ROUTE + "/" + ) { + setIsErrorsView(true); + setIsCompareView(false); + setErrorFields( + new URLSearchParams(location.search).getAll( + "error-field", + ) as SourcePath[], + ); setSourcePath("" as SourcePath); setReady(true); return; } setIsCompareView(false); + setIsErrorsView(false); + setErrorFields([]); const valPathIndex = location.pathname.indexOf(VAL_CONTENT_VIEW_ROUTE); if (valPathIndex > -1) { const modulePath = new URLSearchParams(location.search).get("p"); @@ -112,7 +140,7 @@ export function ValRouter({ } }, 50); } else if (location.hash) { - const scrollToId = decodeURIComponent(location.hash.slice(1)); + const scrollToPath = decodeURIComponent(location.hash.slice(1)); // remove hash: window.history.replaceState( null, @@ -121,10 +149,12 @@ export function ValRouter({ ); let retriesLeft = 100; const execScroll = () => { - if (scrollToId) { + if (scrollToPath) { const shadowRoot = document.getElementById("val-shadow-root")?.shadowRoot; - const element = shadowRoot?.getElementById(scrollToId); + const element = shadowRoot + ? findStudioPathTarget(shadowRoot, scrollToPath) + : null; if (element && shadowRoot) { doScroll(shadowRoot, element); } else if (retriesLeft > 0) { @@ -153,25 +183,47 @@ export function ValRouter({ }, []); const navigate = useCallback( ( - path: SourcePath | ModuleFilePath | typeof VAL_COMPARE_ROUTE, - params?: { scrollToId?: string; replace?: true }, + path: + | SourcePath + | ModuleFilePath + | typeof VAL_COMPARE_ROUTE + | typeof VAL_ERRORS_ROUTE, + params?: { + scrollToPath?: SourcePath | ModuleFilePath; + replace?: true; + errorFields?: SourcePath[]; + }, ) => { const isCompare = path === VAL_COMPARE_ROUTE; + const isErrors = path === VAL_ERRORS_ROUTE; + const errorFieldsQuery = + isErrors && params?.errorFields && params.errorFields.length > 0 + ? "?" + + params.errorFields + .map((p) => `error-field=${encodeURIComponent(p)}`) + .join("&") + : ""; const navigateTo = isCompare ? VAL_COMPARE_ROUTE - : `${VAL_CONTENT_VIEW_ROUTE}${path}`; + : isErrors + ? VAL_ERRORS_ROUTE + errorFieldsQuery + : `${VAL_CONTENT_VIEW_ROUTE}${path}`; setIsCompareView(isCompare); - setSourcePath(isCompare ? ("" as SourcePath) : (path as SourcePath)); + setIsErrorsView(isErrors); + setErrorFields(isErrors ? (params?.errorFields ?? []) : []); + setSourcePath( + isCompare || isErrors ? ("" as SourcePath) : (path as SourcePath), + ); if (!overlay) { const shadowRoot = document.getElementById("val-shadow-root")?.shadowRoot; const scrollContainer = shadowRoot?.getElementById("val-content-area"); const prevScrollPos = scrollContainer?.scrollTop; - const scrollId = params?.scrollToId; - if (scrollId && shadowRoot) { + const scrollToPath = params?.scrollToPath; + if (scrollToPath && shadowRoot) { let retriesLeft = 10; const execScroll = () => { - const element = shadowRoot.getElementById(scrollId); + const element = findStudioPathTarget(shadowRoot, scrollToPath); if (element) { doScroll(shadowRoot, element); } else if (retriesLeft > 0) { @@ -194,7 +246,10 @@ export function ValRouter({ } } else { window.location.href = - navigateTo + (params?.scrollToId ? `#${params.scrollToId}` : ""); + navigateTo + + (params?.scrollToPath + ? `#${encodeURIComponent(params.scrollToPath)}` + : ""); } }, [overlay], @@ -207,6 +262,8 @@ export function ValRouter({ navigate, ready, isCompareView, + isErrorsView, + errorFields, }} > {children} @@ -215,13 +272,21 @@ export function ValRouter({ } export function useNavigation() { - const { navigate, currentSourcePath, ready, isCompareView } = - useContext(ValRouterContext); + const { + navigate, + currentSourcePath, + ready, + isCompareView, + isErrorsView, + errorFields, + } = useContext(ValRouterContext); return { navigate, currentSourcePath, ready, isCompareView, + isErrorsView, + errorFields, }; } diff --git a/packages/ui/spa/components/ValidationErrors.stories.tsx b/packages/ui/spa/components/ValidationErrors.stories.tsx new file mode 100644 index 000000000..13f99d7df --- /dev/null +++ b/packages/ui/spa/components/ValidationErrors.stories.tsx @@ -0,0 +1,251 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { + initVal, + Internal, + Json, + ModuleFilePath, + ReifiedRender, + SerializedSchema, + SourcePath, + ValidationError, +} from "@valbuild/core"; +import { JSONValue } from "@valbuild/core/patch"; +import { ValClient } from "@valbuild/shared/internal"; +import { useMemo, useState } from "react"; +import { ValidationErrors } from "./ValidationErrors"; +import { ValSyncEngine } from "../ValSyncEngine"; +import { ValThemeProvider, Themes } from "./ValThemeProvider"; +import { ValErrorProvider } from "./ValErrorProvider"; +import { ValPortalProvider } from "./ValPortalProvider"; +import { ValFieldProvider } from "./ValFieldProvider"; +import { ValRouter } from "./ValRouter"; +import { ValRemoteProvider } from "./ValRemoteProvider"; +import { TooltipProvider } from "./designSystem/tooltip"; + +function createMockClient(): ValClient { + return ((path: string, method: string) => { + if (path === "/patches" && method === "PUT") { + return Promise.resolve({ status: 200, json: { newPatchIds: [] } }); + } + return Promise.resolve({ + status: 200, + json: { + schemas: {}, + sources: {}, + config: { project: "storybook-test" }, + }, + }); + }) as unknown as ValClient; +} + +type MockData = { + schemas: Record; + sources: Record; + renders: Record; +}; + +function createMockData( + modules: ReturnType["c"]["define"]>[], +): MockData { + const schemas: Record = {}; + const sources: Record = {}; + const renders: Record = {}; + for (const module of modules) { + const moduleFilePath = Internal.getValPath(module); + const schema = Internal.getSchema(module); + const source = Internal.getSource(module); + if (moduleFilePath && schema && source !== undefined) { + const path = moduleFilePath as unknown as ModuleFilePath; + schemas[path] = schema["executeSerialize"](); + sources[path] = source; + renders[path] = schema["executeRender"](path, source); + } + } + return { + schemas: schemas as Record, + sources: sources as Record, + renders: renders as Record, + }; +} + +function makeEngine(client: ValClient, mockData: MockData): ValSyncEngine { + const engine = new ValSyncEngine(client, undefined); + engine.setSchemas(mockData.schemas); + engine.setSources( + mockData.sources as Record, + ); + engine.setRenders(mockData.renders); + engine.setBaseSha("storybook-mock-sha"); + engine.setInitializedAt(Date.now()); + return engine; +} + +function StoryProviders({ + children, + syncEngine, +}: { + children: React.ReactNode; + syncEngine: ValSyncEngine; +}) { + const [theme, setTheme] = useState("dark"); + const getDirectFileUploadSettings = useMemo( + () => async () => ({ + status: "success" as const, + data: { + nonce: null, + baseUrl: "https://mock-upload.example.com", + contentBaseUrl: null, + contentAuthNonce: null, + }, + }), + [], + ); + return ( + + + + + + + + {children} + + + + + + + + ); +} + +const { s, c } = initVal(); + +const PAGES_PATH = "/app/pages.val.ts" as ModuleFilePath; +const pagesModule = c.define( + "/app/pages.val.ts", + s.record( + s.object({ + title: s.string(), + body: s.string(), + }), + ), + { + "/home": { title: "Welcome Home", body: "Welcome to our site." }, + "/about": { title: "About Us", body: "Learn more about our team." }, + }, +); + +const SETTINGS_PATH = "/app/settings.val.ts" as ModuleFilePath; +const settingsModule = c.define( + "/app/settings.val.ts", + s.object({ + siteName: s.string(), + description: s.string(), + }), + { siteName: "My Site", description: "A great site." }, +); + +const mockData = createMockData([pagesModule, settingsModule]); + +function sourcePathOf( + moduleFilePath: ModuleFilePath, + segments: string[], +): SourcePath { + return Internal.joinModuleFilePathAndModulePath( + moduleFilePath, + Internal.patchPathToModulePath(segments), + ); +} + +const homeTitlePath = sourcePathOf(PAGES_PATH, ["/home", "title"]); +const homeBodyPath = sourcePathOf(PAGES_PATH, ["/home", "body"]); +const aboutTitlePath = sourcePathOf(PAGES_PATH, ["/about", "title"]); +const settingsSiteNamePath = sourcePathOf(SETTINGS_PATH, ["siteName"]); + +const sampleErrors: Record = { + [homeTitlePath]: [{ message: "Title is required." }], + [homeBodyPath]: [ + { message: "Body must be at least 20 characters." }, + { message: "Body should not contain placeholder text." }, + ], + [aboutTitlePath]: [{ message: "Title must be at most 60 characters." }], + [settingsSiteNamePath]: [{ message: "Site name is required." }], +}; + +function StorySetup({ + errorFields, + allErrors, +}: { + errorFields: SourcePath[]; + allErrors: Record; +}) { + const client = useMemo(() => createMockClient(), []); + const engine = useMemo(() => makeEngine(client, mockData), [client]); + return ( + + + + ); +} + +const meta: Meta = { + title: "Components/ValidationErrors", + component: StorySetup, + parameters: { layout: "fullscreen" }, + tags: ["autodocs"], + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +export const Empty: Story = { + args: { errorFields: [], allErrors: {} }, +}; + +export const SingleField: Story = { + args: { + errorFields: [homeTitlePath], + allErrors: sampleErrors, + }, +}; + +export const MultipleFieldsAcrossModules: Story = { + args: { + errorFields: [ + settingsSiteNamePath, + homeBodyPath, + aboutTitlePath, + homeTitlePath, + ], + allErrors: sampleErrors, + }, +}; + +export const SelectedButNoErrorInStore: Story = { + args: { errorFields: [homeTitlePath], allErrors: {} }, +}; + +export const AllFixed: Story = { + args: { + errorFields: [homeTitlePath, homeBodyPath, aboutTitlePath], + allErrors: {}, + }, +}; diff --git a/packages/ui/spa/components/ValidationErrors.tsx b/packages/ui/spa/components/ValidationErrors.tsx new file mode 100644 index 000000000..3a5b2d71a --- /dev/null +++ b/packages/ui/spa/components/ValidationErrors.tsx @@ -0,0 +1,363 @@ +import { + Internal, + ModuleFilePath, + ModulePath, + SourcePath, + ValidationError, +} from "@valbuild/core"; +import { Fragment, useMemo } from "react"; +import classNames from "classnames"; +import { + CheckCircle2, + FileCode2, + Globe, + Loader2, + TriangleAlert, +} from "lucide-react"; +import { AnyField } from "./AnyField"; +import { FieldErrorList } from "./FieldErrorList"; +import { getNavPathFromAll } from "./getNavPath"; +import { useAllValidationErrors } from "./ValErrorProvider"; +import { useAllSources, useSchemaAtPath, useSchemas } from "./ValFieldProvider"; +import { useNavigation } from "./ValRouter"; +import { prettifyFilename } from "../utils/prettifyFilename"; +import { prettifyModulePath } from "../utils/prettifyText"; +import { urlOf } from "@valbuild/shared/internal"; + +/** + * The list of rows shown on `/val/errors` is driven entirely by the + * `error-field` query params at page load — it never reacts to errors + * appearing or disappearing while the user is editing here, so rows are + * stable and the layout doesn't shift. The right-tools "Validation errors" + * button is responsible for re-snapshotting the URL when the user wants a + * fresh view. + * + * The page is grouped by module file, with a count pill per module, so a + * user fixing errors across several files always has an at-a-glance map of + * what's left. Errors use the warning palette rather than error-red so the + * page reads as "needs attention before publish", not "something is broken". + */ +export function ValidationErrors({ + errorFields, + allErrors, +}: { + errorFields: SourcePath[]; + allErrors: Record | null; +}) { + const grouped = useMemo(() => groupByModule(errorFields), [errorFields]); + const totalFields = errorFields.length; + const moduleCount = grouped.length; + const allFixed = + totalFields > 0 && + errorFields.every( + (path) => !allErrors?.[path] || allErrors[path]!.length === 0, + ); + const remainingFieldCount = errorFields.filter( + (path) => allErrors?.[path] && allErrors[path]!.length! > 0, + ).length; + + if (errorFields.length === 0) { + return ( +
+ No errors selected. +
+ ); + } + + return ( +
+
+ +

+ Validation errors +

+ {allFixed ? ( + All fixed + ) : ( + + {totalFields} {totalFields === 1 ? "field" : "fields"} + {moduleCount > 1 ? ( + <> + {" across "} + {moduleCount} modules + + ) : null} + + )} +
+ + {allFixed && } + + {!allFixed && remainingFieldCount < totalFields && totalFields > 1 && ( +
+ {remainingFieldCount} of {totalFields} still need attention. +
+ )} + +
+ {grouped.map(({ moduleFilePath, paths }) => ( + + ))} +
+
+ ); +} + +export function ValidationErrorsView() { + const { errorFields } = useNavigation(); + const allErrors = useAllValidationErrors(); + return ; +} + +function ModuleGroup({ + moduleFilePath, + paths, + allErrors, +}: { + moduleFilePath: ModuleFilePath; + paths: SourcePath[]; + allErrors: Record | null; +}) { + const moduleParts = useMemo( + () => Internal.splitModuleFilePath(moduleFilePath), + [moduleFilePath], + ); + const unresolvedCount = paths.filter( + (path) => allErrors?.[path] && allErrors[path]!.length > 0, + ).length; + return ( +
+
+ +
+ {moduleParts.map((part, i) => ( + + {i > 0 && /} + + {prettifyFilename(part)} + + + ))} +
+
+ {unresolvedCount === 0 ? ( + + + Fixed + + ) : ( + + {unresolvedCount} {unresolvedCount === 1 ? "issue" : "issues"} + + )} +
+
+
    + {paths.map((path, i) => ( + + ))} +
+
+ ); +} + +function ValidationErrorRow({ + sourcePath, + errors, + isLast, +}: { + sourcePath: SourcePath; + errors: ValidationError[] | undefined; + isLast: boolean; +}) { + const schemaAtPath = useSchemaAtPath(sourcePath); + const hasError = !!errors && errors.length > 0; + return ( +
  • + + {schemaAtPath.status === "success" ? ( + + ) : ( +
    + + Loading field… +
    + )} + {hasError ? ( + + ) : ( +
    + + Fixed. +
    + )} +
  • + ); +} + +function FieldPathLabel({ sourcePath }: { sourcePath: SourcePath }) { + const { navigate } = useNavigation(); + const schemas = useSchemas(); + const allSources = useAllSources(); + const [moduleFilePath, modulePath] = + Internal.splitModuleFilePathAndModulePath(sourcePath); + const segments = modulePath + ? Internal.splitModulePath(modulePath as ModulePath) + : []; + const schemasData = schemas.status === "success" ? schemas.data : undefined; + const moduleSchema = schemasData?.[moduleFilePath]; + const isRouterModule = + moduleSchema?.type === "record" && Boolean(moduleSchema.router); + const isRouterPageKey = isRouterModule && segments.length === 1; + + const codeCls = + "font-mono text-sm px-2 py-0.5 rounded bg-bg-secondary text-fg-primary truncate cursor-pointer hover:bg-bg-tertiary transition-colors min-w-0 block"; + + const handleNavigate = () => { + const navPath = getNavPathFromAll(sourcePath, allSources, schemasData); + const target = navPath ?? sourcePath; + navigate(target, { + scrollToPath: target !== sourcePath ? sourcePath : undefined, + }); + }; + + if (!modulePath) { + return ( + + ); + } + + if (isRouterPageKey) { + const segment = segments[0]; + const previewHref = urlOf("/api/val/enable", { + redirect_to: + (typeof window !== "undefined" ? window.location.origin : "") + segment, + }); + return ( + + +
    + + + + ); + } + + return ( + + ); +} + +function CountPill({ + children, + tone, +}: { + children: React.ReactNode; + tone: "warning" | "success"; +}) { + return ( + + {children} + + ); +} + +function AllFixedBanner({ total }: { total: number }) { + return ( +
    + + + All {total}{" "} + {total === 1 ? "error is" : "errors are"} fixed. Ready to publish. + +
    + ); +} + +function groupByModule( + errorFields: SourcePath[], +): { moduleFilePath: ModuleFilePath; paths: SourcePath[] }[] { + const map = new Map(); + for (const path of errorFields) { + const [moduleFilePath] = Internal.splitModuleFilePathAndModulePath(path); + const bucket = map.get(moduleFilePath); + if (bucket) { + bucket.push(path); + } else { + map.set(moduleFilePath, [path]); + } + } + const groups = Array.from(map.entries()).map(([moduleFilePath, paths]) => ({ + moduleFilePath, + paths: paths.slice().sort(compareWithinModule), + })); + groups.sort((a, b) => a.moduleFilePath.localeCompare(b.moduleFilePath)); + return groups; +} + +function compareWithinModule(a: SourcePath, b: SourcePath): number { + const [, aPath] = Internal.splitModuleFilePathAndModulePath(a); + const [, bPath] = Internal.splitModuleFilePathAndModulePath(b); + const aSegs = aPath ? Internal.splitModulePath(aPath as ModulePath) : []; + const bSegs = bPath ? Internal.splitModulePath(bPath as ModulePath) : []; + const min = Math.min(aSegs.length, bSegs.length); + for (let i = 0; i < min; i++) { + if (aSegs[i] !== bSegs[i]) return aSegs[i].localeCompare(bSegs[i]); + } + return aSegs.length - bSegs.length; +} diff --git a/packages/ui/spa/components/designSystem/button.tsx b/packages/ui/spa/components/designSystem/button.tsx index 824579a27..e023de531 100644 --- a/packages/ui/spa/components/designSystem/button.tsx +++ b/packages/ui/spa/components/designSystem/button.tsx @@ -29,6 +29,11 @@ const buttonVariants = cva( "border border-bg-error-primary", "bg-bg-error-primary text-fg-error-primary hover:bg-bg-error-primary-hover disabled:text-fg-error-primary aria-disabled:text-fg-error-primary", ), + warning: cn( + "cursor-pointer", + "border border-bg-warning-secondary", + "bg-bg-warning-secondary text-fg-warning-secondary hover:bg-bg-warning-secondary-hover disabled:text-fg-warning-secondary aria-disabled:text-fg-warning-secondary", + ), outline: cn( "cursor-pointer", "border border-transparent", diff --git a/packages/ui/spa/components/fields/ArrayFields.tsx b/packages/ui/spa/components/fields/ArrayFields.tsx index b1cd0acba..b550b6846 100644 --- a/packages/ui/spa/components/fields/ArrayFields.tsx +++ b/packages/ui/spa/components/fields/ArrayFields.tsx @@ -28,11 +28,13 @@ export function ArrayFields({ readonly, compact, inline, + errorDisplay = "default", }: { path: SourcePath; readonly?: boolean; compact?: boolean; inline?: boolean; + errorDisplay?: "default" | "compact" | "none"; }) { const type = "array"; const creatorId = useFieldCreatorId(); @@ -123,6 +125,7 @@ export function ArrayFields({ type={schema.item.type} readonly={readonly} compact={compact} + errorDisplay={errorDisplay} > diff --git a/packages/ui/spa/components/fields/ObjectFields.tsx b/packages/ui/spa/components/fields/ObjectFields.tsx index 5cd67a217..16d185342 100644 --- a/packages/ui/spa/components/fields/ObjectFields.tsx +++ b/packages/ui/spa/components/fields/ObjectFields.tsx @@ -17,11 +17,13 @@ export function ObjectFields({ readonly, compact, inline, + errorDisplay = "default", }: { path: SourcePath; readonly?: boolean; compact?: boolean; inline?: boolean; + errorDisplay?: "default" | "compact" | "none"; }) { const type = "object"; const schemaAtPath = useSchemaAtPath(path); @@ -76,6 +78,7 @@ export function ObjectFields({ type={itemSchema.type} readonly={readonly} compact={compact} + errorDisplay={errorDisplay} > ); diff --git a/packages/ui/spa/components/fields/RecordFields.tsx b/packages/ui/spa/components/fields/RecordFields.tsx index d2ff40941..ca8a9a40e 100644 --- a/packages/ui/spa/components/fields/RecordFields.tsx +++ b/packages/ui/spa/components/fields/RecordFields.tsx @@ -31,11 +31,13 @@ export function RecordFields({ readonly, compact, inline, + errorDisplay = "default", }: { path: SourcePath; readonly?: boolean; compact?: boolean; inline?: boolean; + errorDisplay?: "default" | "compact" | "none"; }) { const type = "record"; const validationErrors = useAllValidationErrors() || {}; @@ -101,6 +103,7 @@ export function RecordFields({ type={schema.item.type} readonly={readonly} compact={compact} + errorDisplay={errorDisplay} > ))} diff --git a/packages/ui/spa/components/fields/UnionField.tsx b/packages/ui/spa/components/fields/UnionField.tsx index f3389f554..c3dceb41a 100644 --- a/packages/ui/spa/components/fields/UnionField.tsx +++ b/packages/ui/spa/components/fields/UnionField.tsx @@ -48,11 +48,13 @@ export function UnionField({ readonly, compact, inline, + errorDisplay = "default", }: { path: SourcePath; readonly?: boolean; compact?: boolean; inline?: boolean; + errorDisplay?: "default" | "compact" | "none"; }) { const type = "union"; const schemaAtPath = useSchemaAtPath(path); @@ -159,6 +161,7 @@ export function UnionField({ readonly={readonly} compact={compact} inline={inline} + errorDisplay={errorDisplay} />
    ); @@ -171,12 +174,14 @@ function ObjectUnionField({ readonly, compact, inline, + errorDisplay = "default", }: { path: SourcePath; schema: SerializedObjectUnionSchema; readonly?: boolean; compact?: boolean; inline?: boolean; + errorDisplay?: "default" | "compact" | "none"; }) { const fullSourceAtPath = useSourceAtPath(path); const { addPatch, patchPath } = useAddPatch(path); @@ -293,6 +298,7 @@ function ObjectUnionField({ type={selectedSchema?.items?.[key]?.type} readonly={readonly} compact={compact} + errorDisplay={errorDisplay} > ); diff --git a/packages/ui/spa/index.css b/packages/ui/spa/index.css index b238d8b46..bb8fcddc3 100644 --- a/packages/ui/spa/index.css +++ b/packages/ui/spa/index.css @@ -114,7 +114,18 @@ --border-brand-secondary: var(--colors-brand-green-500); /* Warning colors */ + /* Warning primary: soft tinted surface for cards/banners */ --bg-warning-primary: var(--colors-warning-yellow-50); + --bg-warning-primary-hover: var(--colors-warning-yellow-100); + --fg-warning-primary: var(--colors-warning-yellow-800); + --fg-warning-primary-alt: var(--colors-warning-yellow-700); + --border-warning-primary: var(--colors-warning-yellow-300); + /* Warning secondary: filled accent (rail, icon, pill) */ + --bg-warning-secondary: var(--colors-warning-yellow-500); + --bg-warning-secondary-hover: var(--colors-warning-yellow-600); + --fg-warning-secondary: var(--colors-warning-yellow-50); + --fg-warning-secondary-alt: var(--colors-warning-yellow-100); + --border-warning-secondary: var(--colors-warning-yellow-600); /* Error colors */ /* Error primary */ @@ -174,7 +185,18 @@ --border-brand-secondary: var(--colors-brand-green-400); /* Warning colors */ - --bg-warning-primary: var(--colors-warning-yellow-500); + /* Warning primary: soft tinted surface for cards/banners */ + --bg-warning-primary: var(--colors-warning-yellow-950); + --bg-warning-primary-hover: var(--colors-warning-yellow-900); + --fg-warning-primary: var(--colors-warning-yellow-100); + --fg-warning-primary-alt: var(--colors-warning-yellow-200); + --border-warning-primary: var(--colors-warning-yellow-700); + /* Warning secondary: filled accent (rail, icon, pill) */ + --bg-warning-secondary: var(--colors-warning-yellow-400); + --bg-warning-secondary-hover: var(--colors-warning-yellow-300); + --fg-warning-secondary: var(--colors-warning-yellow-950); + --fg-warning-secondary-alt: var(--colors-warning-yellow-900); + --border-warning-secondary: var(--colors-warning-yellow-500); /* Error colors */ /* Error primary */ diff --git a/packages/ui/spa/utils/prettifyText.ts b/packages/ui/spa/utils/prettifyText.ts index 24d1c998c..032a32eaf 100644 --- a/packages/ui/spa/utils/prettifyText.ts +++ b/packages/ui/spa/utils/prettifyText.ts @@ -1,4 +1,18 @@ +import { Internal } from "@valbuild/core"; + export function fromCamelToTitleCase(text: string): string { const result = text.replace(/([A-Z])/g, " $1").toLowerCase(); return result.charAt(0).toUpperCase() + result.slice(1); } + +export function prettifyModulePath(modulePath: string): string { + // Show "Home / Title" instead of '"home"."title"'. Router-page slugs like + // "/blogs/blog-12" start with "/" so the casing helper leaves them intact. + if (!modulePath) return modulePath; + // splitModulePath accepts the same string the engine uses internally; it's + // a branded type at the boundary but the existing helpers are tolerant. + const segments = Internal.splitModulePath( + modulePath as Parameters[0], + ); + return segments.map(fromCamelToTitleCase).join(" / "); +} diff --git a/packages/ui/tailwind.config.js b/packages/ui/tailwind.config.js index 9ac98ef08..9619aa3bc 100644 --- a/packages/ui/tailwind.config.js +++ b/packages/ui/tailwind.config.js @@ -50,6 +50,15 @@ module.exports = { "fg-brand-secondary-alt": "var(--fg-brand-secondary-alt)", "border-brand-secondary": "var(--border-brand-secondary)", "bg-warning-primary": "var(--bg-warning-primary)", + "bg-warning-primary-hover": "var(--bg-warning-primary-hover)", + "fg-warning-primary": "var(--fg-warning-primary)", + "fg-warning-primary-alt": "var(--fg-warning-primary-alt)", + "border-warning-primary": "var(--border-warning-primary)", + "bg-warning-secondary": "var(--bg-warning-secondary)", + "bg-warning-secondary-hover": "var(--bg-warning-secondary-hover)", + "fg-warning-secondary": "var(--fg-warning-secondary)", + "fg-warning-secondary-alt": "var(--fg-warning-secondary-alt)", + "border-warning-secondary": "var(--border-warning-secondary)", "bg-error-primary": "var(--bg-error-primary)", "bg-error-primary-hover": "var(--bg-error-primary-hover)", "fg-error-primary": "var(--fg-error-primary)",