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:
@@ -37,15 +37,16 @@ function handleGlobalError(error: AxiosError) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rate limit
|
// Rate limit — always worth notifying
|
||||||
if (status === 429) {
|
if (status === 429) {
|
||||||
toast.error(detail || 'Too many requests — please try again shortly')
|
toast.error(detail || 'Too many requests — please try again shortly')
|
||||||
return
|
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) {
|
if (status >= 400 && status < 500) {
|
||||||
toast.error(detail || 'Invalid request')
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,14 +40,15 @@ export function NodeEditorPanel({ nodeId, onClose, onSelectType }: NodeEditorPan
|
|||||||
const [showDiscardConfirm, setShowDiscardConfirm] = useState(false)
|
const [showDiscardConfirm, setShowDiscardConfirm] = useState(false)
|
||||||
const panelRef = useRef<HTMLDivElement>(null)
|
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(() => {
|
useEffect(() => {
|
||||||
if (node) {
|
if (node) {
|
||||||
setDraft(cloneWithoutChildren(node))
|
setDraft(cloneWithoutChildren(node))
|
||||||
setIsDirty(false)
|
setIsDirty(false)
|
||||||
setShowDeleteConfirm(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>) => {
|
const handleDraftUpdate = useCallback((updates: Partial<TreeStructure>) => {
|
||||||
setDraft(prev => prev ? { ...prev, ...updates } : prev)
|
setDraft(prev => prev ? { ...prev, ...updates } : prev)
|
||||||
@@ -60,7 +61,7 @@ export function NodeEditorPanel({ nodeId, onClose, onSelectType }: NodeEditorPan
|
|||||||
updateNode(nodeId, draftWithoutChildren)
|
updateNode(nodeId, draftWithoutChildren)
|
||||||
|
|
||||||
// Auto-create answer stubs for new decision options without next_node_id
|
// 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 options = draft.options.filter(o => o.label.trim())
|
||||||
const stubsCreated: Array<{ optId: string; stubId: string }> = []
|
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)
|
setIsDirty(false)
|
||||||
}, [draft, node, nodeId, updateNode, addNode])
|
}, [draft, node, nodeId, updateNode, addNode])
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { DynamicArrayField } from './DynamicArrayField'
|
import { DynamicArrayField } from './DynamicArrayField'
|
||||||
import { NodePicker } from './NodePicker'
|
|
||||||
import { useTreeEditorStore } from '@/store/treeEditorStore'
|
import { useTreeEditorStore } from '@/store/treeEditorStore'
|
||||||
import { MarkdownContent } from '@/components/ui/MarkdownContent'
|
import { MarkdownContent } from '@/components/ui/MarkdownContent'
|
||||||
import { InfoTip } from '@/components/common/InfoTip'
|
import { InfoTip } from '@/components/common/InfoTip'
|
||||||
@@ -20,9 +19,7 @@ export function NodeFormAction({ node, onUpdate }: NodeFormActionProps) {
|
|||||||
e => e.nodeId === node.id && e.field === 'title'
|
e => e.nodeId === node.id && e.field === 'title'
|
||||||
)
|
)
|
||||||
|
|
||||||
const nextNodeError = validationErrors.find(
|
const hasNextNode = !!node.next_node_id
|
||||||
e => e.nodeId === node.id && e.field === 'next_node_id'
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleAddCommand = () => {
|
const handleAddCommand = () => {
|
||||||
onUpdate({
|
onUpdate({
|
||||||
@@ -161,16 +158,16 @@ export function NodeFormAction({ node, onUpdate }: NodeFormActionProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Next Node */}
|
{/* Next step hint */}
|
||||||
<NodePicker
|
{hasNextNode ? (
|
||||||
value={node.next_node_id || ''}
|
<p className="text-xs text-muted-foreground">
|
||||||
onChange={(nodeId) => onUpdate({ next_node_id: nodeId })}
|
Next step is linked — click it on the canvas to edit.
|
||||||
parentNodeId={node.id}
|
</p>
|
||||||
excludeNodeId={node.id}
|
) : (
|
||||||
label="Next Node (after action)"
|
<p className="text-xs text-yellow-400/70">
|
||||||
placeholder="Select or create next node..."
|
Save to create a placeholder for the next step.
|
||||||
error={nextNodeError?.message}
|
</p>
|
||||||
/>
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
@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 {
|
.rdp-custom {
|
||||||
@apply text-foreground;
|
@apply text-foreground;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ createRoot(document.getElementById('root')!).render(
|
|||||||
closeButton
|
closeButton
|
||||||
visibleToasts={3}
|
visibleToasts={3}
|
||||||
gap={8}
|
gap={8}
|
||||||
|
theme="dark"
|
||||||
|
toastOptions={{
|
||||||
|
className: 'sonner-toast-custom',
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<App />
|
<App />
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
|
|||||||
Reference in New Issue
Block a user