fix: toast styling, node editor first-click, action node placeholder pattern

1. Toast fixes: Add theme="dark" to Sonner, use !important CSS overrides
   instead of zero-specificity :where() selectors, suppress noisy 4xx
   global toasts (pages handle their own errors)

2. Node editor first-click: Add node.type to draft initialization
   useEffect deps so draft resets when answer stub converts to real type

3. Action node redesign: Remove NodePicker dropdown, auto-create answer
   placeholder on save (matching decision node pattern). Users click the
   placeholder on canvas to choose type and fill in details.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
chihlasm
2026-02-19 17:36:19 -05:00
parent 3dc635659f
commit 372d412fec
5 changed files with 84 additions and 72 deletions

View File

@@ -37,15 +37,16 @@ function handleGlobalError(error: AxiosError) {
return
}
// Rate limit
// Rate limit — always worth notifying
if (status === 429) {
toast.error(detail || 'Too many requests — please try again shortly')
return
}
// Client errors (4xx) — show backend detail if present
// Client errors (4xx) — don't toast globally.
// Pages handle their own 4xx errors (permission checks, validation, not-found)
// and many are caught silently. Global toasts here cause noisy duplicates.
if (status >= 400 && status < 500) {
toast.error(detail || 'Invalid request')
return
}

View File

@@ -40,14 +40,15 @@ export function NodeEditorPanel({ nodeId, onClose, onSelectType }: NodeEditorPan
const [showDiscardConfirm, setShowDiscardConfirm] = useState(false)
const panelRef = useRef<HTMLDivElement>(null)
// Initialize/reset draft when nodeId changes
// Initialize/reset draft when nodeId changes or when node type changes
// (e.g., answer stub → decision/action/solution via type picker)
useEffect(() => {
if (node) {
setDraft(cloneWithoutChildren(node))
setIsDirty(false)
setShowDeleteConfirm(false)
}
}, [nodeId]) // eslint-disable-line react-hooks/exhaustive-deps
}, [nodeId, node?.type]) // eslint-disable-line react-hooks/exhaustive-deps
const handleDraftUpdate = useCallback((updates: Partial<TreeStructure>) => {
setDraft(prev => prev ? { ...prev, ...updates } : prev)
@@ -60,7 +61,7 @@ export function NodeEditorPanel({ nodeId, onClose, onSelectType }: NodeEditorPan
updateNode(nodeId, draftWithoutChildren)
// Auto-create answer stubs for new decision options without next_node_id
if (draft.options) {
if (draft.type === 'decision' && draft.options) {
const options = draft.options.filter(o => o.label.trim())
const stubsCreated: Array<{ optId: string; stubId: string }> = []
@@ -81,6 +82,13 @@ export function NodeEditorPanel({ nodeId, onClose, onSelectType }: NodeEditorPan
}
}
// Auto-create answer stub for action node without next_node_id
if (draft.type === 'action' && !draft.next_node_id) {
const stubId = addNode(nodeId, 'answer')
updateNode(stubId, { title: 'Next Step' })
updateNode(nodeId, { next_node_id: stubId })
}
setIsDirty(false)
}, [draft, node, nodeId, updateNode, addNode])

View File

@@ -1,6 +1,5 @@
import { useState } from 'react'
import { DynamicArrayField } from './DynamicArrayField'
import { NodePicker } from './NodePicker'
import { useTreeEditorStore } from '@/store/treeEditorStore'
import { MarkdownContent } from '@/components/ui/MarkdownContent'
import { InfoTip } from '@/components/common/InfoTip'
@@ -20,9 +19,7 @@ export function NodeFormAction({ node, onUpdate }: NodeFormActionProps) {
e => e.nodeId === node.id && e.field === 'title'
)
const nextNodeError = validationErrors.find(
e => e.nodeId === node.id && e.field === 'next_node_id'
)
const hasNextNode = !!node.next_node_id
const handleAddCommand = () => {
onUpdate({
@@ -161,16 +158,16 @@ export function NodeFormAction({ node, onUpdate }: NodeFormActionProps) {
/>
</div>
{/* Next Node */}
<NodePicker
value={node.next_node_id || ''}
onChange={(nodeId) => onUpdate({ next_node_id: nodeId })}
parentNodeId={node.id}
excludeNodeId={node.id}
label="Next Node (after action)"
placeholder="Select or create next node..."
error={nextNodeError?.message}
/>
{/* Next step hint */}
{hasNextNode ? (
<p className="text-xs text-muted-foreground">
Next step is linked click it on the canvas to edit.
</p>
) : (
<p className="text-xs text-yellow-400/70">
Save to create a placeholder for the next step.
</p>
)}
</div>
)
}

View File

@@ -198,59 +198,61 @@
}
}
/* Sonner Toast Customization */
/* Sonner Toast Customization — outside @layer for higher specificity */
[data-sonner-toast] {
background-color: hsl(var(--card)) !important;
color: hsl(var(--card-foreground)) !important;
border: 1px solid hsl(var(--border)) !important;
box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.3) !important;
border-radius: 0.75rem;
font-family: 'Inter', system-ui, sans-serif;
}
[data-sonner-toast] [data-title] {
font-family: 'Inter', system-ui, sans-serif;
font-weight: 600;
}
[data-sonner-toast][data-type="success"] {
border-color: rgba(52, 211, 153, 0.3) !important;
}
[data-sonner-toast][data-type="success"] [data-icon] {
color: #34d399;
}
[data-sonner-toast][data-type="error"] {
border-color: rgba(248, 113, 113, 0.3) !important;
}
[data-sonner-toast][data-type="error"] [data-icon] {
color: #f87171;
}
[data-sonner-toast][data-type="info"] {
border-color: hsl(var(--border)) !important;
}
[data-sonner-toast][data-type="info"] [data-icon] {
color: hsl(var(--muted-foreground));
}
[data-sonner-toast][data-type="warning"] {
border-color: rgba(251, 191, 36, 0.3) !important;
}
[data-sonner-toast][data-type="warning"] [data-icon] {
color: #fbbf24;
}
[data-sonner-toast] [data-close-button] {
color: hsl(var(--muted-foreground));
border-radius: 0.375rem;
transition: color 150ms, background-color 150ms;
}
[data-sonner-toast] [data-close-button]:hover {
background-color: hsl(var(--accent));
color: hsl(var(--accent-foreground));
}
/* React Day Picker Customization */
@layer components {
:where([data-sonner-toast]) {
@apply bg-card text-card-foreground;
@apply border border-border shadow-lg;
@apply rounded-xl;
font-family: 'Inter', system-ui, sans-serif;
backdrop-filter: blur(10px);
}
:where([data-sonner-toast]) [data-title] {
font-family: 'Inter', system-ui, sans-serif;
font-weight: 600;
}
:where([data-sonner-toast][data-type="success"]) {
border-color: rgba(52, 211, 153, 0.3);
}
:where([data-sonner-toast][data-type="success"]) [data-icon] {
color: #34d399;
}
:where([data-sonner-toast][data-type="error"]) {
border-color: rgba(248, 113, 113, 0.3);
}
:where([data-sonner-toast][data-type="error"]) [data-icon] {
color: #f87171;
}
:where([data-sonner-toast][data-type="info"]) {
@apply border-border;
}
:where([data-sonner-toast][data-type="info"]) [data-icon] {
@apply text-muted-foreground;
}
:where([data-sonner-toast][data-type="warning"]) {
border-color: rgba(251, 191, 36, 0.3);
}
:where([data-sonner-toast][data-type="warning"]) [data-icon] {
color: #fbbf24;
}
:where([data-sonner-toast]) [data-close-button] {
@apply text-muted-foreground hover:bg-accent hover:text-accent-foreground;
@apply rounded-md transition-colors;
}
:where([data-sonner-toast]) [data-icon][data-loading] {
@apply text-white;
}
/* React Day Picker Customization */
.rdp-custom {
@apply text-foreground;
}

View File

@@ -13,6 +13,10 @@ createRoot(document.getElementById('root')!).render(
closeButton
visibleToasts={3}
gap={8}
theme="dark"
toastOptions={{
className: 'sonner-toast-custom',
}}
/>
<App />
</StrictMode>,