From 31310b49f944859bd1e66fe535d04d4cafdcb326 Mon Sep 17 00:00:00 2001 From: Developing-Gamer Date: Wed, 15 Jul 2026 18:47:42 -0700 Subject: [PATCH 1/6] Refactor DesignEditableGrid with flat layout and explicit edit states Replace ghost hover fields with white control surfaces, add description support, shared popover styling, and clearer deferred-save validation. Co-authored-by: Cursor --- .../design-components/editable-grid.tsx | 917 +++++++++--------- .../src/components/design-components/index.ts | 3 +- 2 files changed, 452 insertions(+), 468 deletions(-) diff --git a/apps/dashboard/src/components/design-components/editable-grid.tsx b/apps/dashboard/src/components/design-components/editable-grid.tsx index d3374184a..6261283d8 100644 --- a/apps/dashboard/src/components/design-components/editable-grid.tsx +++ b/apps/dashboard/src/components/design-components/editable-grid.tsx @@ -1,6 +1,9 @@ "use client"; import { + Popover, + PopoverContent, + PopoverTrigger, Select, SelectContent, SelectItem, @@ -10,29 +13,23 @@ import { Spinner, } from "@/components/ui"; import { cn } from "@/lib/utils"; -import { useAsyncCallback } from "@hexclave/shared/dist/hooks/use-async-callback"; -import { throwErr } from "@hexclave/shared/dist/utils/errors"; import { runAsynchronouslyWithAlert } from "@hexclave/shared/dist/utils/promises"; -import { ArrowCounterClockwise, Check, FloppyDisk, X } from "@phosphor-icons/react"; -import { useRef, useState } from "react"; -import { DesignButton, useDesignEditMode, DesignInput } from "@hexclave/dashboard-ui-components"; +import { DesignButton, useDesignEditMode } from "@hexclave/dashboard-ui-components"; +import { + CaretDown, + Check, + PencilSimple, + X, +} from "@phosphor-icons/react"; +import { useRef, useState, type HTMLAttributes, type ReactNode } from "react"; export type DesignEditableGridSize = "sm" | "md"; -const sizeConfig = { - sm: { height: "h-7", minHeight: "min-h-7", padding: "px-2", customPl: "pl-2", gapX: "gap-x-1.5", interColPl: "lg:[&>*:nth-child(4n+3)]:pl-3" }, - md: { height: "h-8", minHeight: "min-h-8", padding: "px-3", customPl: "pl-3", gapX: "gap-x-3", interColPl: "lg:[&>*:nth-child(4n+3)]:pl-5" }, -} as const; - -// Ghost mode: hide visual decorations (bg, border, shadow, ring) by default -const ghostFieldClasses = "bg-transparent dark:bg-transparent border-transparent dark:border-transparent shadow-none ring-0"; -// Ghost mode: reveal visual decorations on hover -const ghostFieldHoverClasses = "hover:bg-white/80 dark:hover:bg-foreground/[0.03] hover:border-black/[0.08] dark:hover:border-white/[0.06] hover:shadow-sm hover:ring-1 hover:ring-black/[0.08] dark:hover:ring-white/[0.06]"; - type BaseItemProps = { itemKey?: string, - icon: React.ReactNode, + icon: ReactNode, name: string, + description?: string, tooltip?: string, }; @@ -43,6 +40,7 @@ type TextItem = BaseItemProps & { normalizeInput?: (value: string) => string, readOnly?: boolean, placeholder?: string, + inputMode?: HTMLAttributes["inputMode"], }; type BooleanItem = BaseItemProps & { @@ -54,7 +52,7 @@ type BooleanItem = BaseItemProps & { falseLabel?: string, }; -type DropdownOption = { +export type DesignEditableGridDropdownOption = { value: string, label: string, disabled?: boolean, @@ -64,19 +62,20 @@ type DropdownOption = { type DropdownItem = BaseItemProps & { type: "dropdown", value: string, - options: DropdownOption[], + options: DesignEditableGridDropdownOption[], onUpdate?: (value: string) => Promise, readOnly?: boolean, + placeholder?: string, extraAction?: { label: string, - onClick: () => void, + onClick: () => void | Promise, }, }; type CustomDropdownItem = BaseItemProps & { type: "custom-dropdown", - triggerContent: React.ReactNode, - popoverContent: React.ReactNode, + triggerContent: ReactNode, + popoverContent: ReactNode, open?: boolean, onOpenChange?: (open: boolean) => void, disabled?: boolean, @@ -84,14 +83,14 @@ type CustomDropdownItem = BaseItemProps & { type CustomButtonItem = BaseItemProps & { type: "custom-button", - children: React.ReactNode, - onClick: () => void, + children: ReactNode, + onClick: () => void | Promise, disabled?: boolean, }; type CustomContentItem = BaseItemProps & { type: "custom", - children: React.ReactNode, + children: ReactNode, }; export type DesignEditableGridItem = @@ -102,7 +101,7 @@ export type DesignEditableGridItem = | CustomButtonItem | CustomContentItem; -type DesignEditableGridProps = { +export type DesignEditableGridProps = { items: DesignEditableGridItem[], columns?: 1 | 2, size?: DesignEditableGridSize, @@ -112,564 +111,538 @@ type DesignEditableGridProps = { hasChanges?: boolean, onSave?: () => Promise, onDiscard?: () => void, - externalModifiedKeys?: Set, + externalModifiedKeys?: ReadonlySet, + "aria-label"?: string, }; -type DesignEditableInputProps = { - value: string, - onUpdate?: (value: string) => Promise, - normalizeInput?: (value: string) => string, - readOnly?: boolean, - placeholder?: string, - inputClassName?: string, - shiftTextToLeft?: boolean, - mode?: "text" | "password", +type FieldSizeConfig = { + control: "sm" | "md", + controlHeight: string, + controlPadding: string, + controlText: string, + labelHeight: string, + iconSize: string, + gapX: string, + gapY: string, }; -function DesignEditableInput({ - value, - onUpdate, - normalizeInput, - readOnly, - placeholder, - inputClassName, - shiftTextToLeft, - mode = "text", - editMode, - sz, -}: DesignEditableInputProps & { editMode: boolean, sz: typeof sizeConfig[DesignEditableGridSize] }) { - const [editValue, setEditValue] = useState(null); - const [hasChanged, setHasChanged] = useState(false); - const editing = editValue !== null; - const forceAllowBlur = useRef(false); - const containerRef = useRef(null); - const inputRef = useRef(null); - const acceptRef = useRef(null); - const [handleUpdate, isLoading] = useAsyncCallback(async (nextValue: string) => { - await onUpdate?.(nextValue); - }, [onUpdate]); +const sizeConfig = new Map([ + ["sm", { + control: "sm", + controlHeight: "h-8", + controlPadding: "px-2.5", + controlText: "text-sm", + labelHeight: "min-h-8", + iconSize: "h-6 w-6", + gapX: "gap-x-3", + gapY: "gap-y-3", + }], + ["md", { + control: "md", + controlHeight: "h-9", + controlPadding: "px-3", + controlText: "text-sm", + labelHeight: "min-h-9", + iconSize: "h-7 w-7", + gapX: "gap-x-4", + gapY: "gap-y-4", + }], +]); - return
{ - if (!readOnly) { - setEditValue(editValue ?? value); - } - }} - onBlur={() => { - if (forceAllowBlur.current) return; - if (!hasChanged) { - setEditValue(null); - } else if (confirm("You have unapplied changes. Would you like to save them?")) { - acceptRef.current?.click(); - } else { - setEditValue(null); - setHasChanged(false); - } - }} - onMouseDown={(ev) => { - if (containerRef.current?.contains(ev.target as Node)) { - ev.preventDefault(); - return false; - } - }} - > - { - setEditValue(normalizeInput?.(e.target.value) ?? e.target.value); - setHasChanged(true); - }} - onKeyDown={(e) => { - if (e.key === "Enter") { - acceptRef.current?.click(); - } - }} - onMouseDown={(ev) => { - ev.stopPropagation(); - }} - /> -
- {["accept", "reject"].map((action) => ( - runAsynchronouslyWithAlert(async () => { - try { - forceAllowBlur.current = true; - inputRef.current?.blur(); - if (action === "accept") { - await handleUpdate(editValue ?? throwErr("No value to update")); - } - setEditValue(null); - setHasChanged(false); - } finally { - forceAllowBlur.current = false; - } - })} - > - {action === "accept" - ? - : } - - ))} -
-
; +/** + * Shared editable control surface for DesignEditableGrid and page-local + * custom triggers that sit inside a grid value cell. White / neutral only — + * never tinted theme backgrounds that read as purple on glass pages. + */ +export const designEditableGridControlClassName = cn( + "rounded-lg border border-black/[0.1] bg-white text-foreground shadow-none", + "hover:bg-zinc-50 hover:border-black/[0.16]", + "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-black/[0.12]", + "dark:border-white/[0.1] dark:bg-zinc-950 dark:hover:bg-zinc-900 dark:hover:border-white/[0.18]", + "dark:focus-visible:ring-white/[0.16]", + "transition-colors duration-150 hover:transition-none", +); + +export const designEditableGridPopoverClassName = cn( + "w-64 rounded-xl border border-black/[0.1] bg-white p-3 shadow-lg", + "dark:border-white/[0.1] dark:bg-zinc-950", +); + +function getSizeConfig(size: DesignEditableGridSize) { + const config = sizeConfig.get(size); + if (config == null) { + throw new Error(`DesignEditableGrid does not define styles for size "${size}".`); + } + return config; } -function GridLabel({ - icon, - name, - tooltip, - isModified, - sz, +function ReadOnlyValue({ + children, + placeholder, }: { - icon: React.ReactNode, - name: string, - tooltip?: string, - isModified?: boolean, - sz: typeof sizeConfig[DesignEditableGridSize], + children: ReactNode, + placeholder?: string, }) { - const label = ( - - - {icon} - - {name} - + const hasValue = children !== ""; + + return ( + + {hasValue ? children : (placeholder ?? "—")} ); - - if (tooltip) { - return ( - - {label} - - ); - } - - return label; } -function EditableBooleanField({ - value, - onUpdate, - readOnly, - trueLabel = "Yes", - falseLabel = "No", +function EditableTextField({ + item, editMode, - sz, + size, }: { - value: boolean, - onUpdate?: (value: boolean) => Promise, - readOnly?: boolean, - trueLabel?: string, - falseLabel?: string, + item: TextItem, editMode: boolean, - sz: typeof sizeConfig[DesignEditableGridSize], + size: FieldSizeConfig, }) { - const [isUpdating, setIsUpdating] = useState(false); + const [editing, setEditing] = useState(false); + const [draftValue, setDraftValue] = useState(item.value); + const [isSaving, setIsSaving] = useState(false); + const inputRef = useRef(null); + const canEdit = item.readOnly !== true && item.onUpdate != null; - const handleChange = async (newValue: string) => { - if (!onUpdate) return; - setIsUpdating(true); + const beginEditing = () => { + if (!canEdit) { + return; + } + setDraftValue(item.value); + setEditing(true); + }; + + const cancelEditing = () => { + setDraftValue(item.value); + setEditing(false); + }; + + const save = async () => { + if (item.onUpdate == null) { + throw new Error(`Editable text item "${item.name}" requires an onUpdate handler.`); + } + + setIsSaving(true); try { - await onUpdate(newValue === "true"); + await item.onUpdate(draftValue); + setEditing(false); } finally { - setIsUpdating(false); + setIsSaving(false); } }; - if (readOnly) { + if (!canEdit && !editing) { return ( - - {value ? trueLabel : falseLabel} - +
+ {item.value} +
); } + // Idle and edit share the same shell so height/width/radius never jump — + // only the border weight and trailing actions change. return ( -
- { + const nextValue = event.target.value; + setDraftValue(item.normalizeInput?.(nextValue) ?? nextValue); + }} + onClick={(event) => event.stopPropagation()} + onKeyDown={(event) => { + if (event.key === "Enter" && !event.nativeEvent.isComposing) { + event.preventDefault(); + runAsynchronouslyWithAlert(save()); + } + if (event.key === "Escape") { + cancelEditing(); + } + }} + placeholder={item.placeholder} + value={draftValue} className={cn( - "w-full rounded-xl text-sm text-foreground", sz.height, sz.padding, - "bg-white/80 dark:bg-foreground/[0.03] border border-black/[0.08] dark:border-white/[0.06]", - "shadow-sm ring-1 ring-black/[0.08] dark:ring-white/[0.06]", - "hover:text-foreground hover:bg-white dark:hover:bg-foreground/[0.06]", - "transition-colors duration-150 hover:transition-none", - "[&>svg]:h-3.5 [&>svg]:w-3.5 [&>svg]:opacity-50", - isUpdating && "[&_span]:invisible", - !editMode && ghostFieldClasses, - !editMode && ghostFieldHoverClasses, - !editMode && "[&>svg]:opacity-0 hover:[&>svg]:opacity-50", + "min-w-0 flex-1 bg-transparent text-sm text-foreground outline-none", + "placeholder:text-muted-foreground/70", + "disabled:cursor-not-allowed disabled:opacity-50", )} - > - - - - {trueLabel} - {falseLabel} - - - {isUpdating && ( - + ) : ( + + {item.value} + + )} + {editing ? ( +
event.stopPropagation()}> + + + + + + +
+ ) : ( + )}
); } -function EditableDropdownField({ +function AsyncSelectField({ value, options, onUpdate, readOnly, - extraAction, + placeholder, + name, editMode, - sz, + size, + extraAction, }: { value: string, - options: DropdownOption[], + options: DesignEditableGridDropdownOption[], onUpdate?: (value: string) => Promise, readOnly?: boolean, - extraAction?: { label: string, onClick: () => void }, + placeholder?: string, + name: string, editMode: boolean, - sz: typeof sizeConfig[DesignEditableGridSize], + size: FieldSizeConfig, + extraAction?: DropdownItem["extraAction"], }) { const [isUpdating, setIsUpdating] = useState(false); + const selectedOption = options.find((option) => option.value === value); + const canEdit = readOnly !== true && onUpdate != null; - const handleChange = async (newValue: string) => { - if (!onUpdate) return; + if (!canEdit) { + return ( +
+ {selectedOption?.label ?? value} +
+ ); + } + + const updateValue = async (nextValue: string) => { setIsUpdating(true); try { - await onUpdate(newValue); + await onUpdate(nextValue); } finally { setIsUpdating(false); } }; - const selectedOption = options.find(option => option.value === value); - - if (readOnly) { - return ( - - {selectedOption?.label ?? value} - - ); - } - return (
{isUpdating && ( )}
); } -function CustomButtonField({ - children, - onClick, - disabled, - editMode, - sz, -}: { - children: React.ReactNode, - onClick: () => void | Promise, - disabled?: boolean, - editMode: boolean, - sz: typeof sizeConfig[DesignEditableGridSize], -}) { - return ( - - {children} - - ); -} - function CustomDropdownField({ - triggerContent, - disabled, + item, editMode, - sz, + size, }: { - triggerContent: React.ReactNode, - disabled?: boolean, + item: CustomDropdownItem, editMode: boolean, - sz: typeof sizeConfig[DesignEditableGridSize], + size: FieldSizeConfig, }) { return ( - + + + + + + {item.popoverContent} + + ); } -function GridItemValue({ item, editMode, sz }: { item: DesignEditableGridItem, editMode: boolean, sz: typeof sizeConfig[DesignEditableGridSize] }) { +function ItemValue({ + item, + editMode, + size, +}: { + item: DesignEditableGridItem, + editMode: boolean, + size: FieldSizeConfig, +}) { switch (item.type) { case "text": { - return ( - - ); + return ; } case "boolean": { + const onUpdate = item.onUpdate; return ( - await onUpdate(value === "true")} + options={[ + { value: "true", label: item.trueLabel ?? "Yes" }, + { value: "false", label: item.falseLabel ?? "No" }, + ]} + readOnly={item.readOnly} + size={size} + value={item.value ? "true" : "false"} /> ); } case "dropdown": { return ( - ); } case "custom-dropdown": { - return ( - - ); + return ; } case "custom-button": { return ( - runAsynchronouslyWithAlert(item.onClick())} + className={cn( + "flex w-full items-center justify-start gap-2 text-left font-normal", + size.controlHeight, + size.controlPadding, + size.controlText, + designEditableGridControlClassName, + editMode && "border-black/[0.18] dark:border-white/[0.22]", + item.disabled && "cursor-not-allowed opacity-50", + )} > - {item.children} - + {item.children} + ); } case "custom": { - return
{item.children}
; + return
{item.children}
; } } } -function GridItemContent({ item, isModified, editMode, sz }: { item: DesignEditableGridItem, isModified?: boolean, editMode: boolean, sz: typeof sizeConfig[DesignEditableGridSize] }) { - return ( - <> - -
- -
- +function ItemLabel({ + item, + isModified, + size, +}: { + item: DesignEditableGridItem, + isModified: boolean, + size: FieldSizeConfig, +}) { + const label = ( +
+ + {item.icon} + + + + {item.name} + {isModified && ( + + )} + + {item.description != null && ( + + {item.description} + + )} + +
); + + if (item.tooltip == null) { + return label; + } + + return {label}; } -function DesignInlineSaveDiscard({ - hasChanges, - onSave, +function SaveBar({ onDiscard, + onSave, }: { - hasChanges: boolean, - onSave: () => Promise, onDiscard: () => void, + onSave: () => Promise, }) { - const [handleSave, isSaving] = useAsyncCallback(onSave, [onSave]); - return (
- - - Discard - - - - Save - +
+ + Unsaved changes +
+
+ + Discard + + + Save changes + +
); } @@ -681,44 +654,54 @@ export function DesignEditableGrid({ className, editMode: editModeProp, deferredSave = true, - hasChanges, + hasChanges = false, onSave, onDiscard, externalModifiedKeys, + "aria-label": ariaLabel = "Editable settings", }: DesignEditableGridProps) { const contextEditMode = useDesignEditMode(); const editMode = editModeProp ?? contextEditMode; - const sz = sizeConfig[size]; + const resolvedSize = getSizeConfig(size); + + if ((onSave == null) !== (onDiscard == null)) { + throw new Error("DesignEditableGrid requires both onSave and onDiscard when either callback is provided."); + } + if (deferredSave && hasChanges && (onSave == null || onDiscard == null)) { + throw new Error("DesignEditableGrid cannot display pending changes without onSave and onDiscard callbacks."); + } const gridCols = columns === 1 - ? "grid-cols-[min-content_1fr]" - : "grid-cols-[min-content_1fr] lg:grid-cols-[min-content_1fr_min-content_1fr]"; + ? "grid-cols-[minmax(7.5rem,max-content)_minmax(0,1fr)]" + : "grid-cols-[minmax(7.5rem,max-content)_minmax(0,1fr)] lg:grid-cols-[minmax(7.5rem,max-content)_minmax(0,1fr)_minmax(7.5rem,max-content)_minmax(0,1fr)]"; return ( -
-
- {items.map((item, index) => ( - - ))} +
+
*:nth-child(4n+3)]:pl-4", + gridCols, + className, + )} + > + {items.map((item, index) => { + const isModified = item.itemKey != null && externalModifiedKeys?.has(item.itemKey) === true; + const key = item.itemKey ?? `${item.type}-${item.name}-${index}`; + return ( +
+ +
+ +
+
+ ); + })}
- {deferredSave && onSave && onDiscard && ( - + {deferredSave && hasChanges && onSave != null && onDiscard != null && ( + )}
); diff --git a/apps/dashboard/src/components/design-components/index.ts b/apps/dashboard/src/components/design-components/index.ts index 5725b0be4..c62fb9f3a 100644 --- a/apps/dashboard/src/components/design-components/index.ts +++ b/apps/dashboard/src/components/design-components/index.ts @@ -14,7 +14,8 @@ export type { } from "../../../../../packages/dashboard-ui-components/src/components/dialog"; export * from "./analytics-card"; export * from "./design-tokens"; -export * from "./editable-grid"; +export * from "./editable-grid.tsx"; +export type * from "./editable-grid.tsx"; export * from "./list"; export * from "./menu"; export * from "./select"; From de67cb1364c9cd293b3604718f3d3ec339ff9b36 Mon Sep 17 00:00:00 2001 From: Developing-Gamer Date: Wed, 15 Jul 2026 18:47:44 -0700 Subject: [PATCH 2/6] Document DesignEditableGrid props, layout rules, and usage patterns Capture the flat label/value grid, edit-state behavior, and shared control class helpers in the design guide. Co-authored-by: Cursor --- apps/dashboard/DESIGN-GUIDE.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/dashboard/DESIGN-GUIDE.md b/apps/dashboard/DESIGN-GUIDE.md index f5700d108..194c9a0d1 100644 --- a/apps/dashboard/DESIGN-GUIDE.md +++ b/apps/dashboard/DESIGN-GUIDE.md @@ -520,14 +520,22 @@ Use for: Props: - `items` (typed union: `text`, `boolean`, `dropdown`, `custom-dropdown`, `custom-button`, `custom`) +- shared item metadata: `itemKey`, `icon`, `name`, optional `description` and `tooltip` - `columns`: `1 | 2` +- `size`: `"sm" | "md"` - `deferredSave`, `hasChanges`, `onSave`, `onDiscard` - `externalModifiedKeys` Rules: - prefer this for config forms that are row-based and editable inline +- layout is a flat label/value grid (no per-field cards); editable values use white controls in light mode - use deferred save mode when many fields should be committed together +- text values enter an explicit edit state; Enter saves and Escape cancels +- omit `onUpdate` or set `readOnly` for a non-editable value +- use `description` for persistent context and `tooltip` only for secondary details +- `custom-dropdown` owns its popover; provide both `triggerContent` and `popoverContent` +- for page-local editable triggers outside the typed union, reuse `designEditableGridControlClassName` / `designEditableGridPopoverClassName` ### 4.12 `DataGrid` + `useDataSource` + `createDefaultDataGridState` From 2bd7f5b60486a40445c381b970ff6ae1287f1d4f Mon Sep 17 00:00:00 2001 From: Developing-Gamer Date: Wed, 15 Jul 2026 18:47:46 -0700 Subject: [PATCH 3/6] Update playground EditableGrid demo for the redesigned component Add field descriptions, refresh the demo container styling, and align prop ordering with the new grid API. Co-authored-by: Cursor --- .../playground/page-client.tsx | 28 +++++++++++-------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/playground/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/playground/page-client.tsx index 209ae95a0..f5374aa86 100644 --- a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/playground/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/playground/page-client.tsx @@ -536,6 +536,7 @@ export default function PageClient() { type: "text", icon: , name: "Display Name", + description: "The customer-facing name for this product.", value: "Widget Pro", readOnly: false, onUpdate: async () => { @@ -548,6 +549,7 @@ export default function PageClient() { type: "boolean", icon: , name: "Active", + description: "Controls whether this product can be purchased.", value: true, readOnly: false, trueLabel: "Yes", @@ -562,6 +564,7 @@ export default function PageClient() { type: "dropdown", icon: , name: "Category", + description: "Used to organize this product in the catalog.", value: "hardware", options: [ { value: "hardware", label: "Hardware" }, @@ -579,6 +582,7 @@ export default function PageClient() { type: "custom", icon: , name: "Price", + description: "Current base price before discounts.", children: $29.99, }, ]; @@ -591,6 +595,7 @@ export default function PageClient() { type: "custom-dropdown", icon: , name: "Custom Dropdown", + description: "A composed control with custom popover content.", triggerContent: Open custom panel, popoverContent:
Custom content
, disabled: false, @@ -600,6 +605,7 @@ export default function PageClient() { type: "custom-button", icon: , name: "Custom Button", + description: "Runs a custom action with built-in loading behavior.", onClick: () => setGridActionLog("Clicked custom button"), children: Run action, disabled: false, @@ -1082,28 +1088,28 @@ export default function PageClient() { if (selected === "editable-grid") { return (
-
+
{ - await new Promise((r) => setTimeout(r, 400)); - setGridActionLog("Saved deferred changes"); - setGridHasChanges(false); - } : undefined} + items={editableItems} onDiscard={gridDeferredSave ? () => { setGridActionLog("Discarded deferred changes"); setGridHasChanges(false); } : undefined} - externalModifiedKeys={gridShowModified ? new Set(["display-name", "category"]) : undefined} + onSave={gridDeferredSave ? async () => { + await new Promise((resolve) => setTimeout(resolve, 400)); + setGridActionLog("Saved deferred changes"); + setGridHasChanges(false); + } : undefined} + size={gridSize} /> {gridActionLog && ( - + Last action: {gridActionLog} )} From a557c0157f2d4dc16675afdf972e66fe8fcbcf20 Mon Sep 17 00:00:00 2001 From: Developing-Gamer Date: Wed, 15 Jul 2026 18:47:48 -0700 Subject: [PATCH 4/6] Adopt custom-dropdown free trial editor and fix sidebar footer background Migrate the product free-trial field to DesignEditableGrid custom-dropdown with design components, and add a subtle background to the sticky sidebar footer so it stays readable over scrolling content. Co-authored-by: Cursor --- .../products/[productId]/page-client.tsx | 112 ++++++++---------- .../projects/[projectId]/sidebar-layout.tsx | 2 +- 2 files changed, 51 insertions(+), 63 deletions(-) diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/page-client.tsx index 71efa7ddb..1d70528ed 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/page-client.tsx @@ -1,6 +1,12 @@ "use client"; -import { DesignEditableGrid, type DesignEditableGridItem } from "@/components/design-components"; +import { + DesignEditableGrid, + DesignButton, + DesignInput, + DesignSelectorDropdown, + type DesignEditableGridItem, +} from "@/components/design-components"; import { EditableInput } from "@/components/editable-input"; import { Link, StyledLink } from "@/components/link"; import { ItemDialog } from "@/components/payments/item-dialog"; @@ -26,9 +32,6 @@ import { DropdownMenuTrigger, Input, Label, - Popover, - PopoverContent, - PopoverTrigger, Select, SelectContent, SelectItem, @@ -647,69 +650,54 @@ function ProductDetailsSection({ productId, product, config }: ProductDetailsSec ) : 'No', }, { - type: 'custom', + type: 'custom-dropdown', itemKey: 'freeTrial', icon: , name: "Free Trial", tooltip: "Free trial period before billing starts. Customers won't be charged during this period.", - children: ( - - - - - -
-
- setFreeTrialCount(parseInt(e.target.value) || 1)} - /> - -
-
- - {localFreeTrial && ( - - )} -
-
-
-
+ Apply + + {localFreeTrial && ( + + Remove + + )} +
+
), }, { diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx index 9e2cf81ce..909a960d2 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx @@ -614,7 +614,7 @@ function SidebarContent({
From fc2f817af9cefb6152780178ba3d35e6314dda43 Mon Sep 17 00:00:00 2001 From: Developing-Gamer Date: Wed, 15 Jul 2026 19:29:49 -0700 Subject: [PATCH 5/6] More design improvements --- .../products/[productId]/page-client.tsx | 325 ++++++++++++------ .../design-components/editable-grid.tsx | 82 ++++- .../src/components/design-components/index.ts | 13 +- 3 files changed, 305 insertions(+), 115 deletions(-) diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/page-client.tsx index 1d70528ed..6700371be 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/page-client.tsx @@ -1,13 +1,15 @@ "use client"; import { + DesignBadge, DesignEditableGrid, DesignButton, DesignInput, + DesignMenu, DesignSelectorDropdown, + type DesignBadgeColor, type DesignEditableGridItem, } from "@/components/design-components"; -import { EditableInput } from "@/components/editable-input"; import { Link, StyledLink } from "@/components/link"; import { ItemDialog } from "@/components/payments/item-dialog"; import { useRouter } from "@/components/router"; @@ -26,10 +28,6 @@ import { DialogFooter, DialogHeader, DialogTitle, - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, Input, Label, Select, @@ -45,7 +43,8 @@ import { } from "@/components/ui"; import { createDefaultDataGridState, DataGrid, useDataGridUrlState, useDataSource, type DataGridColumnDef } from "@hexclave/dashboard-ui-components"; import { useUpdateConfig } from "@/components/config-update"; -import { ArrowLeftIcon, ClockIcon, CopyIcon, CurrencyDollarIcon, DotsThreeIcon, FolderOpenIcon, GiftIcon, HardDriveIcon, PackageIcon, PencilSimpleIcon, PlusIcon, PuzzlePieceIcon, ShoppingCartIcon, StackIcon, TagIcon, TrashIcon, UsersIcon } from "@phosphor-icons/react"; +import { ArrowLeftIcon, Check, ClockIcon, CopyIcon, CurrencyDollarIcon, FolderOpenIcon, GiftIcon, HardDriveIcon, PackageIcon, PencilSimpleIcon, PlusIcon, PuzzlePieceIcon, ShoppingCartIcon, StackIcon, TagIcon, TrashIcon, UsersIcon, X } from "@phosphor-icons/react"; +import { runAsynchronouslyWithAlert } from "@hexclave/shared/dist/utils/promises"; import { CreateCheckoutDialog } from "@/components/payments/create-checkout-dialog"; import type { CustomerType } from "@/components/payments/customer-selector"; import type { CompleteConfig } from "@hexclave/shared/dist/config/schema"; @@ -54,8 +53,7 @@ import type { DayInterval } from "@hexclave/shared/dist/utils/dates"; import { fromNow } from "@hexclave/shared/dist/utils/dates"; import { prettyPrintWithMagnitudes } from "@hexclave/shared/dist/utils/numbers"; import { typedEntries } from "@hexclave/shared/dist/utils/objects"; -import { runAsynchronouslyWithAlert } from "@hexclave/shared/dist/utils/promises"; -import { Suspense, useMemo, useState } from "react"; +import { Suspense, useMemo, useRef, useState } from "react"; import { PageLayout } from "../../../page-layout"; import { useAdminApp, useProjectId } from "../../../use-admin-app"; import { CreateProductLineDialog } from "../create-product-line-dialog"; @@ -74,6 +72,16 @@ const CUSTOMER_TYPE_COLORS = { custom: 'bg-amber-500/15 text-amber-600 dark:bg-amber-500/20 dark:text-amber-400 ring-amber-500/30', } as const; +const CUSTOMER_TYPE_BADGE_COLORS = new Map([ + ["user", "blue"], + ["team", "green"], + ["custom", "orange"], +]); + +function getCustomerTypeBadgeColor(customerType: string): DesignBadgeColor { + return CUSTOMER_TYPE_BADGE_COLORS.get(customerType) ?? "blue"; +} + export default function PageClient({ productId }: { productId: string }) { const adminApp = useAdminApp(); const project = adminApp.useProject(); @@ -116,15 +124,15 @@ function ProductPage({ productId, product, config }: ProductPageProps) {
{canGoBack && ( - + )} @@ -144,119 +152,240 @@ type ProductHeaderProps = { productLineName: string | null, }; +function ProductTitleEditor({ + productId, + displayName, + storedDisplayName, +}: { + productId: string, + displayName: string, + storedDisplayName: string, +}) { + const adminApp = useAdminApp(); + const updateConfig = useUpdateConfig(); + const [editing, setEditing] = useState(false); + const [draftValue, setDraftValue] = useState(storedDisplayName); + const [isSaving, setIsSaving] = useState(false); + const inputRef = useRef(null); + + const beginEditing = () => { + setDraftValue(storedDisplayName); + setEditing(true); + }; + + const cancelEditing = () => { + setDraftValue(storedDisplayName); + setEditing(false); + }; + + const save = async () => { + setIsSaving(true); + try { + const success = await updateConfig({ + adminApp, + configUpdate: { + [`payments.products.${productId}.displayName`]: draftValue || null, + }, + pushable: true, + }); + if (success) { + toast({ title: "Product name updated" }); + setEditing(false); + } + } finally { + setIsSaving(false); + } + }; + + return ( +
{ + if (!editing) { + beginEditing(); + } else { + inputRef.current?.focus(); + } + }} + onKeyDown={(event) => { + if (editing) { + return; + } + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + beginEditing(); + } + }} + > + {editing ? ( + event.stopPropagation()} + onChange={(event) => setDraftValue(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter" && !event.nativeEvent.isComposing) { + event.preventDefault(); + runAsynchronouslyWithAlert(save()); + } + if (event.key === "Escape") { + cancelEditing(); + } + }} + className="min-w-0 flex-1 bg-transparent text-lg font-semibold leading-none tracking-tight text-foreground outline-none placeholder:text-muted-foreground/60 disabled:opacity-50" + /> + ) : ( +

+ {displayName} +

+ )} + {editing ? ( +
event.stopPropagation()}> + + + + + + +
+ ) : ( + + )} +
+ ); +} + function ProductHeader({ productId, product, productLineName }: ProductHeaderProps) { const projectId = useProjectId(); const adminApp = useAdminApp(); const project = adminApp.useProject(); const config = project.useConfig(); - const updateConfig = useUpdateConfig(); const router = useRouter(); const [isCheckoutOpen, setIsCheckoutOpen] = useState(false); const displayName = product.displayName || productId; - const isAddOn = product.isAddOnTo !== false && typeof product.isAddOnTo === 'object'; + const isAddOn = product.isAddOnTo !== false && typeof product.isAddOnTo === "object"; - // Find add-on parent products const addOnParents = useMemo(() => { - if (product.isAddOnTo === false || typeof product.isAddOnTo !== 'object') return []; - return Object.keys(product.isAddOnTo).map((parentId: string) => ({ + if (product.isAddOnTo === false || typeof product.isAddOnTo !== "object") { + return []; + } + return Object.keys(product.isAddOnTo).map((parentId) => ({ id: parentId, displayName: config.payments.products[parentId].displayName || parentId, })); }, [product.isAddOnTo, config.payments.products]); return ( -
-
+
+
{isAddOn ? ( - + ) : ( - + )}
-
+ +
-
- { - const success = await updateConfig({ adminApp, configUpdate: { - [`payments.products.${productId}.displayName`]: newName || null, - }, pushable: true }); - if (success) { - toast({ title: "Product name updated" }); - } - }} + +
+ + , + onClick: () => setIsCheckoutOpen(true), + }, + { + id: "product-lines", + label: "View in Product Lines", + icon: , + onClick: () => router.push(`/projects/${projectId}/payments/product-lines#product-${productId}`), + }, + ]} />
- - - - - - - - } onClick={() => setIsCheckoutOpen(true)}> - Create checkout - - router.push(`/projects/${projectId}/payments/product-lines#product-${productId}`)}> - View in Product Lines - - -
-
- - {product.customerType} + +
+ + + ID: {productId} - ID: {productId} - {productLineName && ( - <> - - Product Line: {productLineName} - + {productLineName != null && ( + + · + Product Line: {productLineName} + )} {addOnParents.length > 0 && ( - <> - - - Add-on to{' '} - {addOnParents.map((p: { id: string, displayName: string }, i: number) => ( - - {i > 0 && ", "} - - {p.displayName} - - - ))} - - + + · + Add-on to{" "} + {addOnParents.map((parent, index) => ( + + {index > 0 && ,} + + {parent.displayName} + + + ))} + )}
diff --git a/apps/dashboard/src/components/design-components/editable-grid.tsx b/apps/dashboard/src/components/design-components/editable-grid.tsx index 6261283d8..54955c6e6 100644 --- a/apps/dashboard/src/components/design-components/editable-grid.tsx +++ b/apps/dashboard/src/components/design-components/editable-grid.tsx @@ -1,6 +1,7 @@ "use client"; import { + Checkbox, Popover, PopoverContent, PopoverTrigger, @@ -348,6 +349,71 @@ function EditableTextField({ ); } +function EditableBooleanField({ + item, + editMode, + size, +}: { + item: BooleanItem, + editMode: boolean, + size: FieldSizeConfig, +}) { + const [isUpdating, setIsUpdating] = useState(false); + const canEdit = item.readOnly !== true && item.onUpdate != null; + const trueLabel = item.trueLabel ?? "Yes"; + const falseLabel = item.falseLabel ?? "No"; + const statusLabel = item.value ? trueLabel : falseLabel; + + if (!canEdit) { + return ( +
+ {statusLabel} +
+ ); + } + + const updateValue = async (nextValue: boolean) => { + if (item.onUpdate == null) { + throw new Error(`Editable boolean item "${item.name}" requires an onUpdate handler.`); + } + setIsUpdating(true); + try { + await item.onUpdate(nextValue); + } finally { + setIsUpdating(false); + } + }; + + return ( + + ); +} + function AsyncSelectField({ value, options, @@ -510,21 +576,7 @@ function ItemValue({ return ; } case "boolean": { - const onUpdate = item.onUpdate; - return ( - await onUpdate(value === "true")} - options={[ - { value: "true", label: item.trueLabel ?? "Yes" }, - { value: "false", label: item.falseLabel ?? "No" }, - ]} - readOnly={item.readOnly} - size={size} - value={item.value ? "true" : "false"} - /> - ); + return ; } case "dropdown": { return ( diff --git a/apps/dashboard/src/components/design-components/index.ts b/apps/dashboard/src/components/design-components/index.ts index c62fb9f3a..54cd784d0 100644 --- a/apps/dashboard/src/components/design-components/index.ts +++ b/apps/dashboard/src/components/design-components/index.ts @@ -14,8 +14,17 @@ export type { } from "../../../../../packages/dashboard-ui-components/src/components/dialog"; export * from "./analytics-card"; export * from "./design-tokens"; -export * from "./editable-grid.tsx"; -export type * from "./editable-grid.tsx"; +export { + DesignEditableGrid, + designEditableGridControlClassName, + designEditableGridPopoverClassName, +} from "./editable-grid"; +export type { + DesignEditableGridDropdownOption, + DesignEditableGridItem, + DesignEditableGridProps, + DesignEditableGridSize, +} from "./editable-grid"; export * from "./list"; export * from "./menu"; export * from "./select"; From c3786b81be720885c2a9cadced61dbaec7c91aa1 Mon Sep 17 00:00:00 2001 From: armaan Date: Thu, 16 Jul 2026 17:19:19 +0000 Subject: [PATCH 6/6] Apply review feedback to editable-grid and product page Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../products/[productId]/page-client.tsx | 22 ++++++++++-- .../design-components/editable-grid.tsx | 34 ++++++++++++++----- 2 files changed, 45 insertions(+), 11 deletions(-) diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/page-client.tsx index 6700371be..dbede0eb4 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/page-client.tsx @@ -241,7 +241,9 @@ function ProductTitleEditor({ onKeyDown={(event) => { if (event.key === "Enter" && !event.nativeEvent.isComposing) { event.preventDefault(); - runAsynchronouslyWithAlert(save()); + if (draftValue !== storedDisplayName && !isSaving) { + runAsynchronouslyWithAlert(save()); + } } if (event.key === "Escape") { cancelEditing(); @@ -352,6 +354,12 @@ function ProductHeader({ productId, product, productLineName }: ProductHeaderPro icon: , onClick: () => router.push(`/projects/${projectId}/payments/product-lines#product-${productId}`), }, + { + id: "edit", + label: "Edit full details", + icon: , + onClick: () => router.push(`/projects/${projectId}/payments/products/${productId}/edit`), + }, ]} />
@@ -690,6 +698,14 @@ function ProductDetailsSection({ productId, product, config }: ProductDetailsSec setFreeTrialPopoverOpen(false); }; + const handleFreeTrialPopoverOpenChange = (open: boolean) => { + setFreeTrialPopoverOpen(open); + if (open) { + setFreeTrialCount(localFreeTrial ? localFreeTrial[0] : 7); + setFreeTrialUnit(localFreeTrial ? localFreeTrial[1] : 'day'); + } + }; + // ===== PRICES HANDLERS (for deferred save) ===== const handlePricesChange = (newPrices: Product['prices']) => { // Clear the "needs at least one price" error as soon as the user adds one back. @@ -785,7 +801,7 @@ function ProductDetailsSection({ productId, product, config }: ProductDetailsSec name: "Free Trial", tooltip: "Free trial period before billing starts. Customers won't be charged during this period.", open: freeTrialPopoverOpen, - onOpenChange: setFreeTrialPopoverOpen, + onOpenChange: handleFreeTrialPopoverOpenChange, triggerContent: localFreeTrialDisplayText, popoverContent: (
@@ -795,7 +811,7 @@ function ProductDetailsSection({ productId, product, config }: ProductDetailsSec type="number" min={1} value={freeTrialCount} - onChange={(e) => setFreeTrialCount(parseInt(e.target.value) || 1)} + onChange={(e) => setFreeTrialCount(Math.max(1, parseInt(e.target.value) || 1))} /> { if (event.key === "Enter" && !event.nativeEvent.isComposing) { event.preventDefault(); - runAsynchronouslyWithAlert(save()); + if (draftValue !== item.value && !isSaving) { + runAsynchronouslyWithAlert(save()); + } } if (event.key === "Escape") { cancelEditing(); @@ -598,10 +600,10 @@ function ItemValue({ } case "custom-button": { return ( - + ); } case "custom": { @@ -673,6 +674,17 @@ function SaveBar({ onDiscard: () => void, onSave: () => Promise, }) { + const [isSaving, setIsSaving] = useState(false); + + const handleSave = async () => { + setIsSaving(true); + try { + await onSave(); + } finally { + setIsSaving(false); + } + }; + return (
Discard - + Save changes
@@ -734,7 +752,7 @@ export function DesignEditableGrid({ "grid items-center text-sm", resolvedSize.gapX, resolvedSize.gapY, - columns === 2 && "lg:[&>*:nth-child(4n+3)]:pl-4", + columns === 2 && "lg:[&>div:nth-child(even)>:first-child]:pl-4", gridCols, className, )}