Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 75 additions & 19 deletions apps/console/src/components/CustomObjectFieldTemplate.tsx
Original file line number Diff line number Diff line change
@@ -1,66 +1,122 @@
import type {
ObjectFieldTemplateProps,
ObjectFieldTemplatePropertyType,
RJSFSchema,
StrictRJSFSchema,
FormContextType,
} from "@rjsf/utils"

function isSimpleField(schema: any): boolean {
if (!schema) return true
const type = schema.type
if (type === "object") return false
if (type === "array") {
const itemType = schema.items?.type
return itemType === "integer" || itemType === "string" || itemType === "number"
}
if (schema.anyOf || schema.oneOf || schema.allOf) {
if (schema["x-kubernetes-int-or-string"]) return true
return false
}
return true
}

function groupByComplexity(
properties: ObjectFieldTemplatePropertyType[],
parentSchema: any,
) {
type Group = { simple: boolean; items: ObjectFieldTemplatePropertyType[] }
const groups: Group[] = []
let current: Group | null = null

for (const prop of properties) {
const fieldSchema = parentSchema?.properties?.[prop.name]
const simple = isSimpleField(fieldSchema)
if (!current || current.simple !== simple) {
current = { simple, items: [] }
groups.push(current)
}
current.items.push(prop)
}
return groups
}

export function CustomObjectFieldTemplate<
T = any,
S extends StrictRJSFSchema = RJSFSchema,
F extends FormContextType = any,
>(props: ObjectFieldTemplateProps<T, S, F>) {
const { formData } = props

// Check if this is an addon object (has 'enabled' field and other config fields)
// Addon pattern: has 'enabled' + other config fields → conditional expand
const hasEnabledField = props.properties.some((p) => p.name === "enabled")
const hasOtherFields = props.properties.some((p) => p.name !== "enabled")
const isAddon = hasEnabledField && hasOtherFields

// If this is an addon, use conditional rendering based on 'enabled' state
if (isAddon) {
const isEnabled = (formData as any)?.enabled === true
const enabledProp = props.properties.find((p) => p.name === "enabled")
const otherProps = props.properties.filter((p) => p.name !== "enabled")
const groups = groupByComplexity(otherProps, props.schema)

return (
<fieldset id={props.idSchema.$id} className="border border-slate-200 rounded-lg p-4 mb-3">
<fieldset id={props.idSchema.$id} className="border border-slate-200 rounded-lg p-3 mb-3">
{props.title && (
<legend className="text-sm font-medium text-slate-900 px-2">{props.title}</legend>
<legend className="text-xs font-semibold text-slate-700 px-1">{props.title}</legend>
)}
{props.description && <p className="field-description text-xs text-slate-500 mb-3">{props.description}</p>}

{/* Always show the 'enabled' checkbox */}
{enabledProp && (
<div className="mb-3">
{enabledProp.content}
</div>
{props.description && (
<p className="field-description text-xs text-slate-400 mb-2">{props.description}</p>
)}

{/* Show other fields only if enabled */}
{enabledProp && <div className="mb-2">{enabledProp.content}</div>}

{isEnabled && otherProps.length > 0 && (
<div className="space-y-3 pl-6 border-l-2 border-blue-200">
{otherProps.map((prop) => (
<div key={prop.name}>{prop.content}</div>
))}
<div className="pl-4 border-l-2 border-blue-200 space-y-0.5">
{groups.map((group, i) =>
group.simple ? (
<div key={i} className="grid-fields grid grid-cols-2 gap-x-3 xl:grid-cols-3">
{group.items.map((prop) => (
<div key={prop.name} className={group.items.length === 1 ? "col-span-full" : ""}>{prop.content}</div>
))}
</div>
) : (
group.items.map((prop) => (
<div key={prop.name}>{prop.content}</div>
))
)
)}
</div>
)}

{!isEnabled && otherProps.length > 0 && (
<p className="text-xs text-slate-400 italic mt-2">
<p className="text-xs text-slate-400 italic mt-1.5">
Enable this addon to configure additional settings
</p>
)}
</fieldset>
)
}

// Otherwise, use default rendering
// Default: smart 2-column grid for simple fields, full width for complex
const groups = groupByComplexity(props.properties, props.schema)

return (
<fieldset id={props.idSchema.$id}>
{props.title && <legend>{props.title}</legend>}
{props.description && <p className="field-description">{props.description}</p>}
{props.properties.map((prop) => prop.content)}
{groups.map((group, i) =>
group.simple ? (
<div key={i} className="grid-fields grid grid-cols-2 gap-x-3 xl:grid-cols-3">
{group.items.map((prop) => (
<div key={prop.name} className={group.items.length === 1 ? "col-span-full" : ""}>{prop.content}</div>
))}
</div>
) : (
group.items.map((prop) => (
<div key={prop.name}>{prop.content}</div>
))
)
)}
</fieldset>
)
}
32 changes: 16 additions & 16 deletions apps/console/src/components/command-palette/command-palette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -126,18 +126,18 @@ export function CommandPalette() {
aria-label="Command palette"
>
{/* Header with optional back + search */}
<div className="flex items-center border-b border-slate-200 px-3" onKeyDown={handleKeyDown}>
<div className="flex items-center gap-2 border-b border-slate-100 px-4" onKeyDown={handleKeyDown}>
{breadcrumb ? (
<button
type="button"
onClick={goBack}
className="mr-2 flex items-center gap-1 shrink-0 rounded px-1.5 py-0.5 text-xs font-medium text-slate-500 hover:bg-slate-100 hover:text-slate-900 transition-colors"
className="flex items-center gap-1 shrink-0 rounded-md px-2 py-1 text-xs font-medium text-slate-500 bg-slate-100 hover:bg-slate-200 hover:text-slate-900 transition-colors"
>
<ArrowLeft className="h-3 w-3" />
{breadcrumb}
</button>
) : (
<Search className="mr-2 h-4 w-4 shrink-0 text-slate-400" />
<Search className="h-4 w-4 shrink-0 text-slate-400" />
)}
<input
ref={inputRef}
Expand All @@ -148,37 +148,37 @@ export function CommandPalette() {
? `Search in ${breadcrumb}...`
: "Type a command or search..."
}
className="flex h-11 w-full bg-transparent py-3 text-sm outline-none placeholder:text-slate-400"
className="h-12 w-full bg-transparent text-sm text-slate-900 outline-none placeholder:text-slate-400"
/>
{!breadcrumb && (
<kbd className="pointer-events-none ml-2 hidden h-5 select-none items-center gap-0.5 rounded border border-slate-200 bg-slate-50 px-1.5 font-mono text-[10px] font-medium text-slate-600 sm:flex">
{isMac ? <span className="text-xs">⌘</span> : <span className="text-xs">Ctrl+</span>}K
<kbd className="pointer-events-none ml-1 hidden shrink-0 select-none items-center rounded-md border border-slate-200 bg-slate-50 px-1.5 py-0.5 font-mono text-[10px] font-medium text-slate-500 sm:flex">
{isMac ? "⌘K" : "Ctrl+K"}
</kbd>
)}
</div>

{/* Results list */}
<div
className="max-h-[300px] overflow-y-auto p-1"
className="max-h-[320px] overflow-y-auto p-1.5"
role="listbox"
aria-label="Results"
>
{items.length === 0 && !isLoading && (
<div className="py-6 text-center text-sm text-slate-500">
<div className="py-8 text-center text-sm text-slate-400">
No results found.
</div>
)}

{isLoading && items.length === 0 && (
<div className="py-6 text-center text-sm text-slate-500">
Loading...
<div className="py-8 text-center text-sm text-slate-400">
Loading
</div>
)}

{grouped.map((group) => (
<div key={group.label} role="group" aria-label={group.label}>
{group.label && (
<div className="px-2 py-1.5 text-xs font-medium text-slate-500">
<div className="px-2 pb-1 pt-2 text-[10px] font-semibold uppercase tracking-widest text-slate-400">
{group.label}
</div>
)}
Expand Down Expand Up @@ -223,8 +223,8 @@ const PaletteItem = forwardRef<
role="option"
aria-selected={highlighted}
className={cn(
"relative flex cursor-default items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none select-none",
highlighted && "bg-slate-100 text-slate-900"
"relative flex cursor-default items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm outline-none select-none transition-colors",
highlighted ? "bg-blue-50 text-blue-900" : "text-slate-700 hover:bg-slate-50"
)}
onClick={onSelect}
>
Expand All @@ -233,14 +233,14 @@ const PaletteItem = forwardRef<
{icon}
</span>
)}
<span className="flex-1 truncate">{label}</span>
<span className="flex-1 truncate font-medium">{label}</span>
{description && (
<span className="text-xs text-slate-500 truncate max-w-[200px]">
<span className={cn("text-xs truncate max-w-[180px]", highlighted ? "text-blue-500" : "text-slate-400")}>
{description}
</span>
)}
{drilldown && (
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-slate-400" />
<ChevronRight className={cn("h-3.5 w-3.5 shrink-0", highlighted ? "text-blue-400" : "text-slate-300")} />
)}
</div>
)
Expand Down
37 changes: 34 additions & 3 deletions apps/console/src/components/rjsf-templates.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type {
IconButtonProps,
ArrayFieldTemplateItemType,
TemplatesType,
FormContextType,
RJSFSchema,
Expand Down Expand Up @@ -31,16 +32,46 @@ function IconButton<
}

const buttonClassName =
"rounded-md border border-slate-300 bg-white px-3 py-1.5 text-sm font-medium text-slate-700 hover:bg-slate-50"
"rounded-md border border-slate-200 bg-white px-3 py-1.5 text-sm font-medium text-slate-600 shadow-sm hover:bg-slate-50 hover:border-slate-300 transition-colors"

const removeButtonClassName =
"rounded-md border border-red-300 bg-white px-3 py-1.5 text-sm font-medium text-red-600 hover:bg-red-50"
"rounded-md border border-red-200 bg-white px-3 py-1.5 text-sm font-medium text-red-600 shadow-sm hover:bg-red-50 hover:border-red-300 transition-colors"

function ArrayFieldItemTemplate<
T = any,
S extends StrictRJSFSchema = RJSFSchema,
F extends FormContextType = any,
>(props: ArrayFieldTemplateItemType<T, S, F>) {
const { children, hasRemove, index, onDropIndexClick, disabled, readonly, schema } = props
const isNumeric = (schema as any).type === 'integer' || (schema as any).type === 'number'
return (
<div className="array-item-row group flex items-center gap-1.5 mb-1">
<div className={isNumeric ? 'w-36 shrink-0' : 'flex-1'}>{children}</div>
{hasRemove && (
<button
type="button"
aria-label="Remove"
className="opacity-0 group-hover:opacity-100 size-[26px] flex-shrink-0 flex items-center justify-center rounded-full border border-red-200 bg-white text-red-400 text-sm leading-none hover:bg-red-50 hover:text-red-600 hover:border-red-300 disabled:opacity-30 transition-all duration-150"
onClick={onDropIndexClick(index)}
disabled={disabled || readonly}
>
×
</button>
)}
Comment on lines +50 to +60
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

group-hover:opacity-100 overrides disabled:opacity-30 due to higher CSS specificity

The compound selector for group-hover:opacity-100 (.group:hover .child, specificity 0,3,0) beats disabled:opacity-30 (.element:disabled, specificity 0,2,0). In Tailwind v4 utilities are not wrapped in :where(), so when the button is disabled (e.g., due to an maxItems constraint while the form is otherwise interactive) and the user hovers the parent row, the button becomes fully visible (opacity 1) but non-functional — a confusing affordance.

🐛 Proposed fix — suppress group-hover reveal when disabled/readonly
-    <div className="array-item-row group flex items-center gap-1.5 mb-1">
+    <div className={`array-item-row flex items-center gap-1.5 mb-1${!(disabled || readonly) ? ' group' : ''}`}>
       <div className={isNumeric ? 'w-36 shrink-0' : 'flex-1'}>{children}</div>
       {hasRemove && (
         <button
           type="button"
           aria-label="Remove"
-          className="opacity-0 group-hover:opacity-100 size-[26px] flex-shrink-0 flex items-center justify-center rounded-full border border-red-200 bg-white text-red-400 text-sm leading-none hover:bg-red-50 hover:text-red-600 hover:border-red-300 disabled:opacity-30 transition-all duration-150"
+          className="opacity-0 group-hover:opacity-100 size-[26px] flex-shrink-0 flex items-center justify-center rounded-full border border-red-200 bg-white text-red-400 text-sm leading-none hover:bg-red-50 hover:text-red-600 hover:border-red-300 transition-all duration-150"
           onClick={onDropIndexClick(index)}
           disabled={disabled || readonly}
         >

By conditionally omitting the group class on the row when the action is unavailable, the group-hover: variant never fires, keeping the button invisible regardless of hover state.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/console/src/components/rjsf-templates.tsx` around lines 50 - 60, The
Remove button's visibility is incorrectly overridden by the parent's group-hover
rule; update the parent row element that adds the "group" class so it is
included only when the remove action is available (i.e., when hasRemove is true
and not disabled/readonly) so the group-hover:opacity-100 variant never fires
for disabled or readonly rows; locate the row/container that renders the button
(the element that currently sets "group" in the className near the Remove
button), and change its class logic to conditionally include "group" only when
!(disabled || readonly) (references: hasRemove, onDropIndexClick, disabled,
readonly).

</div>
)
}

export const customTemplates = {
ObjectFieldTemplate: CustomObjectFieldTemplate,
ArrayFieldItemTemplate: ArrayFieldItemTemplate,
ButtonTemplates: {
AddButton: (props: IconButtonProps) => (
<IconButton {...props} icon="+ Add" className={buttonClassName} />
<IconButton
{...props}
icon="+ add"
className="mt-0.5 flex items-center gap-1 px-2 py-1 text-xs font-medium text-slate-500 hover:text-blue-600 rounded-md border border-dashed border-slate-300 hover:border-blue-400 hover:bg-blue-50/60 bg-white transition-all duration-150"
/>
),
RemoveButton: (props: IconButtonProps) => (
<IconButton {...props} icon="× Remove" className={removeButtonClassName} />
Expand Down
Loading