mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
improvement(react): replace unnecessary useEffect patterns with better React primitives (#3675)
* improvement(react): replace unnecessary useEffect patterns with better React primitives * fix(react): revert unsafe render-time side effects to useEffect * fix(react): restore useEffect for modals, scroll, and env sync - Modals (create-workspace, rename-document, edit-knowledge-base): restore useEffect watching `open` prop for form reset on programmatic open, since Radix onOpenChange doesn't fire for parent-driven prop changes - Popover: add useEffect watching `open` for programmatic close reset - Chat scroll: restore useEffect watching `isStreamingResponse` so the 1s suppression timer starts when streaming begins, not before the fetch - Credentials manager: revert render-time pattern to useEffect for initial sync from cached React Query data (useRef captures initial value, making the !== check always false on mount) * fix(react): restore useEffect for help/invite modals, combobox index reset - Help modal: restore useEffect watching `open` for form reset on programmatic open (same Radix onOpenChange pattern as other modals) - Invite modal: restore useEffect watching `open` to clear error on programmatic open - Combobox: restore useEffect to reset highlightedIndex when filtered options shrink (prevents stale index from reappearing when options grow) - Remove no-op handleOpenChange wrappers in rename-document and edit-knowledge-base modals (now pure pass-throughs after useEffect fix) * fix(context-menu): use requestAnimationFrame for ColorGrid focus, remove no-op wrapper in create-workspace-modal - ColorGrid: replaced setTimeout with requestAnimationFrame for initial focus to wait for submenu paint completion - create-workspace-modal: removed handleOpenChange pass-through wrapper, use onOpenChange directly * fix(files): restore filesRef pattern to prevent preview mode reset on refetch The useEffect that sets previewMode should only run when selectedFileId changes, not when files array reference changes from React Query refetch. Restores the filesRef pattern to read latest files without triggering the effect — prevents overriding user's manual mode selection. * fix(add-documents-modal, combobox): restore useEffect for modal reset, fix combobox dep array - add-documents-modal: handleOpenChange(true) is dead code in Radix controlled mode — restored useEffect watching open for reset-on-open - combobox: depend on filteredOptions array (not .length) so highlight resets when items change even with same count
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { Eye, EyeOff } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
@@ -99,15 +99,21 @@ export default function LoginPage({
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [_mounted, setMounted] = useState(false)
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [password, setPassword] = useState('')
|
||||
const [passwordErrors, setPasswordErrors] = useState<string[]>([])
|
||||
const [showValidationError, setShowValidationError] = useState(false)
|
||||
const buttonClass = useBrandedButtonClass()
|
||||
|
||||
const [callbackUrl, setCallbackUrl] = useState('/workspace')
|
||||
const [isInviteFlow, setIsInviteFlow] = useState(false)
|
||||
const callbackUrlParam = searchParams?.get('callbackUrl')
|
||||
const invalidCallbackRef = useRef(false)
|
||||
if (callbackUrlParam && !validateCallbackUrl(callbackUrlParam) && !invalidCallbackRef.current) {
|
||||
invalidCallbackRef.current = true
|
||||
logger.warn('Invalid callback URL detected and blocked:', { url: callbackUrlParam })
|
||||
}
|
||||
const callbackUrl =
|
||||
callbackUrlParam && validateCallbackUrl(callbackUrlParam) ? callbackUrlParam : '/workspace'
|
||||
const isInviteFlow = searchParams?.get('invite_flow') === 'true'
|
||||
|
||||
const [forgotPasswordOpen, setForgotPasswordOpen] = useState(false)
|
||||
const [forgotPasswordEmail, setForgotPasswordEmail] = useState('')
|
||||
@@ -120,30 +126,11 @@ export default function LoginPage({
|
||||
const [email, setEmail] = useState('')
|
||||
const [emailErrors, setEmailErrors] = useState<string[]>([])
|
||||
const [showEmailValidationError, setShowEmailValidationError] = useState(false)
|
||||
const [resetSuccessMessage, setResetSuccessMessage] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
|
||||
if (searchParams) {
|
||||
const callback = searchParams.get('callbackUrl')
|
||||
if (callback) {
|
||||
if (validateCallbackUrl(callback)) {
|
||||
setCallbackUrl(callback)
|
||||
} else {
|
||||
logger.warn('Invalid callback URL detected and blocked:', { url: callback })
|
||||
}
|
||||
}
|
||||
|
||||
const inviteFlow = searchParams.get('invite_flow') === 'true'
|
||||
setIsInviteFlow(inviteFlow)
|
||||
|
||||
const resetSuccess = searchParams.get('resetSuccess') === 'true'
|
||||
if (resetSuccess) {
|
||||
setResetSuccessMessage('Password reset successful. Please sign in with your new password.')
|
||||
}
|
||||
}
|
||||
}, [searchParams])
|
||||
const [resetSuccessMessage, setResetSuccessMessage] = useState<string | null>(() =>
|
||||
searchParams?.get('resetSuccess') === 'true'
|
||||
? 'Password reset successful. Please sign in with your new password.'
|
||||
: null
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { Suspense, useEffect, useState } from 'react'
|
||||
import { Suspense, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import Link from 'next/link'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
@@ -22,14 +22,9 @@ function ResetPasswordContent() {
|
||||
text: '',
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setStatusMessage({
|
||||
type: 'error',
|
||||
text: 'Invalid or missing reset token. Please request a new password reset link.',
|
||||
})
|
||||
}
|
||||
}, [token])
|
||||
const tokenError = !token
|
||||
? 'Invalid or missing reset token. Please request a new password reset link.'
|
||||
: null
|
||||
|
||||
const handleResetPassword = async (password: string) => {
|
||||
try {
|
||||
@@ -87,8 +82,8 @@ function ResetPasswordContent() {
|
||||
token={token}
|
||||
onSubmit={handleResetPassword}
|
||||
isSubmitting={isSubmitting}
|
||||
statusType={statusMessage.type}
|
||||
statusMessage={statusMessage.text}
|
||||
statusType={tokenError ? 'error' : statusMessage.type}
|
||||
statusMessage={tokenError ?? statusMessage.text}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { Suspense, useEffect, useState } from 'react'
|
||||
import { Suspense, useMemo, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { Eye, EyeOff } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
@@ -82,49 +82,32 @@ function SignupFormContent({
|
||||
const searchParams = useSearchParams()
|
||||
const { refetch: refetchSession } = useSession()
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [, setMounted] = useState(false)
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [password, setPassword] = useState('')
|
||||
const [passwordErrors, setPasswordErrors] = useState<string[]>([])
|
||||
const [showValidationError, setShowValidationError] = useState(false)
|
||||
const [email, setEmail] = useState('')
|
||||
const [email, setEmail] = useState(() => searchParams.get('email') ?? '')
|
||||
const [emailError, setEmailError] = useState('')
|
||||
const [emailErrors, setEmailErrors] = useState<string[]>([])
|
||||
const [showEmailValidationError, setShowEmailValidationError] = useState(false)
|
||||
const [redirectUrl, setRedirectUrl] = useState('')
|
||||
const [isInviteFlow, setIsInviteFlow] = useState(false)
|
||||
const buttonClass = useBrandedButtonClass()
|
||||
|
||||
const redirectUrl = useMemo(
|
||||
() => searchParams.get('redirect') || searchParams.get('callbackUrl') || '',
|
||||
[searchParams]
|
||||
)
|
||||
const isInviteFlow = useMemo(
|
||||
() =>
|
||||
searchParams.get('invite_flow') === 'true' ||
|
||||
redirectUrl.startsWith('/invite/') ||
|
||||
redirectUrl.startsWith('/credential-account/'),
|
||||
[searchParams, redirectUrl]
|
||||
)
|
||||
|
||||
const [name, setName] = useState('')
|
||||
const [nameErrors, setNameErrors] = useState<string[]>([])
|
||||
const [showNameValidationError, setShowNameValidationError] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
const emailParam = searchParams.get('email')
|
||||
if (emailParam) {
|
||||
setEmail(emailParam)
|
||||
}
|
||||
|
||||
// Check both 'redirect' and 'callbackUrl' params (login page uses callbackUrl)
|
||||
const redirectParam = searchParams.get('redirect') || searchParams.get('callbackUrl')
|
||||
if (redirectParam) {
|
||||
setRedirectUrl(redirectParam)
|
||||
|
||||
if (
|
||||
redirectParam.startsWith('/invite/') ||
|
||||
redirectParam.startsWith('/credential-account/')
|
||||
) {
|
||||
setIsInviteFlow(true)
|
||||
}
|
||||
}
|
||||
|
||||
const inviteFlowParam = searchParams.get('invite_flow')
|
||||
if (inviteFlowParam === 'true') {
|
||||
setIsInviteFlow(true)
|
||||
}
|
||||
}, [searchParams])
|
||||
|
||||
const validatePassword = (passwordValue: string): string[] => {
|
||||
const errors: string[] = []
|
||||
|
||||
|
||||
@@ -71,11 +71,6 @@ export const ChatInput: React.FC<{
|
||||
}
|
||||
}
|
||||
|
||||
// Adjust height on input change
|
||||
useEffect(() => {
|
||||
adjustTextareaHeight()
|
||||
}, [inputValue])
|
||||
|
||||
// Close the input when clicking outside (only when empty)
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
@@ -94,17 +89,14 @@ export const ChatInput: React.FC<{
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [inputValue])
|
||||
|
||||
// Handle focus and initial height when activated
|
||||
useEffect(() => {
|
||||
if (isActive && textareaRef.current) {
|
||||
textareaRef.current.focus()
|
||||
adjustTextareaHeight() // Adjust height when becoming active
|
||||
}
|
||||
}, [isActive])
|
||||
|
||||
const handleActivate = () => {
|
||||
setIsActive(true)
|
||||
// Focus is now handled by the useEffect above
|
||||
requestAnimationFrame(() => {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.focus()
|
||||
adjustTextareaHeight()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Handle file selection
|
||||
@@ -186,6 +178,7 @@ export const ChatInput: React.FC<{
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setInputValue(e.target.value)
|
||||
adjustTextareaHeight()
|
||||
}
|
||||
|
||||
// Handle voice start with smooth transition to voice-first mode
|
||||
|
||||
@@ -78,9 +78,10 @@ export function VoiceInterface({
|
||||
const currentStateRef = useRef<'idle' | 'listening' | 'agent_speaking'>('idle')
|
||||
const isCallEndedRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
currentStateRef.current = state
|
||||
}, [state])
|
||||
const updateState = useCallback((next: 'idle' | 'listening' | 'agent_speaking') => {
|
||||
setState(next)
|
||||
currentStateRef.current = next
|
||||
}, [])
|
||||
|
||||
const recognitionRef = useRef<SpeechRecognition | null>(null)
|
||||
const mediaStreamRef = useRef<MediaStream | null>(null)
|
||||
@@ -97,9 +98,10 @@ export function VoiceInterface({
|
||||
(window as WindowWithSpeech).webkitSpeechRecognition
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
isMutedRef.current = isMuted
|
||||
}, [isMuted])
|
||||
const updateIsMuted = useCallback((next: boolean) => {
|
||||
setIsMuted(next)
|
||||
isMutedRef.current = next
|
||||
}, [])
|
||||
|
||||
const setResponseTimeout = useCallback(() => {
|
||||
if (responseTimeoutRef.current) {
|
||||
@@ -108,7 +110,7 @@ export function VoiceInterface({
|
||||
|
||||
responseTimeoutRef.current = setTimeout(() => {
|
||||
if (currentStateRef.current === 'listening') {
|
||||
setState('idle')
|
||||
updateState('idle')
|
||||
}
|
||||
}, 5000)
|
||||
}, [])
|
||||
@@ -123,10 +125,10 @@ export function VoiceInterface({
|
||||
useEffect(() => {
|
||||
if (isPlayingAudio && state !== 'agent_speaking') {
|
||||
clearResponseTimeout()
|
||||
setState('agent_speaking')
|
||||
updateState('agent_speaking')
|
||||
setCurrentTranscript('')
|
||||
|
||||
setIsMuted(true)
|
||||
updateIsMuted(true)
|
||||
if (mediaStreamRef.current) {
|
||||
mediaStreamRef.current.getAudioTracks().forEach((track) => {
|
||||
track.enabled = false
|
||||
@@ -141,17 +143,17 @@ export function VoiceInterface({
|
||||
}
|
||||
}
|
||||
} else if (!isPlayingAudio && state === 'agent_speaking') {
|
||||
setState('idle')
|
||||
updateState('idle')
|
||||
setCurrentTranscript('')
|
||||
|
||||
setIsMuted(false)
|
||||
updateIsMuted(false)
|
||||
if (mediaStreamRef.current) {
|
||||
mediaStreamRef.current.getAudioTracks().forEach((track) => {
|
||||
track.enabled = true
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [isPlayingAudio, state, clearResponseTimeout])
|
||||
}, [isPlayingAudio, state, clearResponseTimeout, updateState, updateIsMuted])
|
||||
|
||||
const setupAudio = useCallback(async () => {
|
||||
try {
|
||||
@@ -310,7 +312,7 @@ export function VoiceInterface({
|
||||
return
|
||||
}
|
||||
|
||||
setState('listening')
|
||||
updateState('listening')
|
||||
setCurrentTranscript('')
|
||||
|
||||
if (recognitionRef.current) {
|
||||
@@ -320,10 +322,10 @@ export function VoiceInterface({
|
||||
logger.error('Error starting recognition:', error)
|
||||
}
|
||||
}
|
||||
}, [isInitialized, isMuted, state])
|
||||
}, [isInitialized, isMuted, state, updateState])
|
||||
|
||||
const stopListening = useCallback(() => {
|
||||
setState('idle')
|
||||
updateState('idle')
|
||||
setCurrentTranscript('')
|
||||
|
||||
if (recognitionRef.current) {
|
||||
@@ -333,15 +335,15 @@ export function VoiceInterface({
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
}, [updateState])
|
||||
|
||||
const handleInterrupt = useCallback(() => {
|
||||
if (state === 'agent_speaking') {
|
||||
onInterrupt?.()
|
||||
setState('listening')
|
||||
updateState('listening')
|
||||
setCurrentTranscript('')
|
||||
|
||||
setIsMuted(false)
|
||||
updateIsMuted(false)
|
||||
if (mediaStreamRef.current) {
|
||||
mediaStreamRef.current.getAudioTracks().forEach((track) => {
|
||||
track.enabled = true
|
||||
@@ -356,14 +358,14 @@ export function VoiceInterface({
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [state, onInterrupt])
|
||||
}, [state, onInterrupt, updateState, updateIsMuted])
|
||||
|
||||
const handleCallEnd = useCallback(() => {
|
||||
isCallEndedRef.current = true
|
||||
|
||||
setState('idle')
|
||||
updateState('idle')
|
||||
setCurrentTranscript('')
|
||||
setIsMuted(false)
|
||||
updateIsMuted(false)
|
||||
|
||||
if (recognitionRef.current) {
|
||||
try {
|
||||
@@ -376,7 +378,7 @@ export function VoiceInterface({
|
||||
clearResponseTimeout()
|
||||
onInterrupt?.()
|
||||
onCallEnd?.()
|
||||
}, [onCallEnd, onInterrupt, clearResponseTimeout])
|
||||
}, [onCallEnd, onInterrupt, clearResponseTimeout, updateState, updateIsMuted])
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
@@ -397,7 +399,7 @@ export function VoiceInterface({
|
||||
}
|
||||
|
||||
const newMutedState = !isMuted
|
||||
setIsMuted(newMutedState)
|
||||
updateIsMuted(newMutedState)
|
||||
|
||||
if (mediaStreamRef.current) {
|
||||
mediaStreamRef.current.getAudioTracks().forEach((track) => {
|
||||
@@ -410,7 +412,7 @@ export function VoiceInterface({
|
||||
} else if (state === 'idle') {
|
||||
startListening()
|
||||
}
|
||||
}, [isMuted, state, handleInterrupt, stopListening, startListening])
|
||||
}, [isMuted, state, handleInterrupt, stopListening, startListening, updateIsMuted])
|
||||
|
||||
useEffect(() => {
|
||||
if (isSupported) {
|
||||
|
||||
+20
-10
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { Loader2, RotateCcw, X } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
@@ -75,15 +75,25 @@ export function AddDocumentsModal({
|
||||
}
|
||||
}, [open, clearError])
|
||||
|
||||
/** Handles close with upload guard */
|
||||
const handleOpenChange = useCallback(
|
||||
(newOpen: boolean) => {
|
||||
if (!newOpen) {
|
||||
if (isUploading) return
|
||||
setFiles([])
|
||||
setFileError(null)
|
||||
clearError()
|
||||
setIsDragging(false)
|
||||
setDragCounter(0)
|
||||
setRetryingIndexes(new Set())
|
||||
}
|
||||
onOpenChange(newOpen)
|
||||
},
|
||||
[isUploading, clearError, onOpenChange]
|
||||
)
|
||||
|
||||
const handleClose = () => {
|
||||
if (isUploading) return
|
||||
setFiles([])
|
||||
setFileError(null)
|
||||
clearError()
|
||||
setIsDragging(false)
|
||||
setDragCounter(0)
|
||||
setRetryingIndexes(new Set())
|
||||
onOpenChange(false)
|
||||
handleOpenChange(false)
|
||||
}
|
||||
|
||||
const processFiles = async (fileList: FileList | File[]) => {
|
||||
@@ -220,7 +230,7 @@ export function AddDocumentsModal({
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal open={open} onOpenChange={handleClose}>
|
||||
<Modal open={open} onOpenChange={handleOpenChange}>
|
||||
<ModalContent size='md'>
|
||||
<ModalHeader>New Documents</ModalHeader>
|
||||
|
||||
|
||||
+6
-7
@@ -494,13 +494,12 @@ export function CredentialsManager() {
|
||||
}, [variables])
|
||||
|
||||
useEffect(() => {
|
||||
if (workspaceEnvData) {
|
||||
if (hasSavedRef.current) {
|
||||
hasSavedRef.current = false
|
||||
} else {
|
||||
setWorkspaceVars(workspaceEnvData?.workspace || {})
|
||||
initialWorkspaceVarsRef.current = workspaceEnvData?.workspace || {}
|
||||
}
|
||||
if (!workspaceEnvData) return
|
||||
if (hasSavedRef.current) {
|
||||
hasSavedRef.current = false
|
||||
} else {
|
||||
setWorkspaceVars(workspaceEnvData.workspace || {})
|
||||
initialWorkspaceVarsRef.current = workspaceEnvData.workspace || {}
|
||||
}
|
||||
}, [workspaceEnvData])
|
||||
|
||||
|
||||
+15
-11
@@ -89,21 +89,25 @@ export function HelpModal({ open, onOpenChange, workflowId, workspaceId }: HelpM
|
||||
})
|
||||
|
||||
/**
|
||||
* Reset all state when modal opens/closes
|
||||
* Reset all form and UI state to prepare for a fresh modal session
|
||||
*/
|
||||
const resetModalState = useCallback(() => {
|
||||
setSubmitStatus(null)
|
||||
setImages([])
|
||||
setIsDragging(false)
|
||||
setIsProcessing(false)
|
||||
reset({
|
||||
subject: '',
|
||||
message: '',
|
||||
type: DEFAULT_REQUEST_TYPE,
|
||||
})
|
||||
}, [reset])
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSubmitStatus(null)
|
||||
setImages([])
|
||||
setIsDragging(false)
|
||||
setIsProcessing(false)
|
||||
reset({
|
||||
subject: '',
|
||||
message: '',
|
||||
type: DEFAULT_REQUEST_TYPE,
|
||||
})
|
||||
resetModalState()
|
||||
}
|
||||
}, [open, reset])
|
||||
}, [open, resetModalState])
|
||||
|
||||
/**
|
||||
* Fix z-index for popover/dropdown when inside modal
|
||||
|
||||
+17
-18
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { type RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
@@ -39,29 +39,27 @@ function ColorGrid({
|
||||
hexInput,
|
||||
setHexInput,
|
||||
onColorChange,
|
||||
isOpen,
|
||||
buttonRefs,
|
||||
}: {
|
||||
hexInput: string
|
||||
setHexInput: (color: string) => void
|
||||
onColorChange?: (color: string) => void
|
||||
isOpen: boolean
|
||||
buttonRefs: RefObject<(HTMLButtonElement | null)[]>
|
||||
}) {
|
||||
const [focusedIndex, setFocusedIndex] = useState(-1)
|
||||
const gridRef = useRef<HTMLDivElement>(null)
|
||||
const buttonRefs = useRef<(HTMLButtonElement | null)[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && gridRef.current) {
|
||||
const selectedIndex = WORKFLOW_COLORS.findIndex(
|
||||
({ color }) => color.toLowerCase() === hexInput.toLowerCase()
|
||||
)
|
||||
const initialIndex = selectedIndex >= 0 ? selectedIndex : 0
|
||||
setFocusedIndex(initialIndex)
|
||||
setTimeout(() => {
|
||||
buttonRefs.current[initialIndex]?.focus()
|
||||
}, 50)
|
||||
}
|
||||
}, [isOpen, hexInput])
|
||||
const selectedIndex = WORKFLOW_COLORS.findIndex(
|
||||
({ color }) => color.toLowerCase() === hexInput.toLowerCase()
|
||||
)
|
||||
const idx = selectedIndex >= 0 ? selectedIndex : 0
|
||||
setFocusedIndex(idx)
|
||||
requestAnimationFrame(() => {
|
||||
buttonRefs.current[idx]?.focus()
|
||||
})
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent, index: number) => {
|
||||
@@ -176,10 +174,10 @@ function ColorPickerSubmenu({
|
||||
handleHexFocus: (e: React.FocusEvent<HTMLInputElement>) => void
|
||||
disabled?: boolean
|
||||
}) {
|
||||
const [isSubOpen, setIsSubOpen] = useState(false)
|
||||
const buttonRefs = useRef<(HTMLButtonElement | null)[]>([])
|
||||
|
||||
return (
|
||||
<DropdownMenuSub open={isSubOpen} onOpenChange={setIsSubOpen}>
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger className={disabled ? 'pointer-events-none opacity-50' : ''}>
|
||||
<Palette />
|
||||
Change color
|
||||
@@ -190,7 +188,7 @@ function ColorPickerSubmenu({
|
||||
hexInput={hexInput}
|
||||
setHexInput={setHexInput}
|
||||
onColorChange={onColorChange}
|
||||
isOpen={isSubOpen}
|
||||
buttonRefs={buttonRefs}
|
||||
/>
|
||||
<div className='flex items-center gap-[4px]'>
|
||||
<div
|
||||
@@ -375,6 +373,7 @@ export function ContextMenu({
|
||||
align='start'
|
||||
side='bottom'
|
||||
sideOffset={4}
|
||||
className='max-h-[var(--radix-dropdown-menu-content-available-height,400px)]'
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
{showOpenInNewTab && onOpenInNewTab && (
|
||||
|
||||
+16
-14
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
@@ -33,29 +33,31 @@ export function CreateWorkspaceModal({
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setName('')
|
||||
requestAnimationFrame(() => inputRef.current?.focus())
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
const handleSubmit = async () => {
|
||||
const trimmed = name.trim()
|
||||
if (!trimmed || isCreating) return
|
||||
await onConfirm(trimmed)
|
||||
}, [name, isCreating, onConfirm])
|
||||
}
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
void handleSubmit()
|
||||
}
|
||||
},
|
||||
[handleSubmit]
|
||||
)
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
void handleSubmit()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal open={open} onOpenChange={onOpenChange}>
|
||||
<ModalContent size='sm'>
|
||||
<ModalContent
|
||||
size='sm'
|
||||
onOpenAutoFocus={(e) => {
|
||||
e.preventDefault()
|
||||
inputRef.current?.focus()
|
||||
}}
|
||||
>
|
||||
<ModalHeader>Create Workspace</ModalHeader>
|
||||
<ModalBody>
|
||||
<Input
|
||||
|
||||
+12
-7
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useMemo, useRef } from 'react'
|
||||
import { Loader2, RotateCw, X } from 'lucide-react'
|
||||
import { Badge, Button, Skeleton, Tooltip } from '@/components/emcn'
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
@@ -64,13 +64,18 @@ export const PermissionsTable = ({
|
||||
}: PermissionsTableProps) => {
|
||||
const { data: session } = useSession()
|
||||
const userPerms = useUserPermissionsContext()
|
||||
const [hasLoadedOnce, setHasLoadedOnce] = useState(false)
|
||||
const hasLoadedOnceRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!permissionsLoading && !userPerms.isLoading && !isPendingInvitationsLoading) {
|
||||
setHasLoadedOnce(true)
|
||||
}
|
||||
}, [permissionsLoading, userPerms.isLoading, isPendingInvitationsLoading])
|
||||
if (
|
||||
!hasLoadedOnceRef.current &&
|
||||
!permissionsLoading &&
|
||||
!userPerms.isLoading &&
|
||||
!isPendingInvitationsLoading
|
||||
) {
|
||||
hasLoadedOnceRef.current = true
|
||||
}
|
||||
|
||||
const hasLoadedOnce = hasLoadedOnceRef.current
|
||||
|
||||
const existingUsers: UserPermissions[] = useMemo(
|
||||
() =>
|
||||
|
||||
@@ -591,10 +591,15 @@ export const Sidebar = memo(function Sidebar() {
|
||||
id: 'settings',
|
||||
label: 'Settings',
|
||||
icon: Settings,
|
||||
onClick: () => navigateToSettings(),
|
||||
onClick: () => {
|
||||
if (!isCollapsed) {
|
||||
setSidebarWidth(SIDEBAR_WIDTH.MIN)
|
||||
}
|
||||
navigateToSettings()
|
||||
},
|
||||
},
|
||||
],
|
||||
[workspaceId, navigateToSettings]
|
||||
[workspaceId, navigateToSettings, isCollapsed, setSidebarWidth]
|
||||
)
|
||||
|
||||
const { data: fetchedTasks = [], isLoading: tasksLoading } = useTasks(workspaceId)
|
||||
@@ -636,6 +641,16 @@ export const Sidebar = memo(function Sidebar() {
|
||||
setIsTaskDeleteModalOpen(true)
|
||||
}, [tasks])
|
||||
|
||||
const navigateToPage = useCallback(
|
||||
(path: string) => {
|
||||
if (!isCollapsed) {
|
||||
setSidebarWidth(SIDEBAR_WIDTH.MIN)
|
||||
}
|
||||
router.push(path)
|
||||
},
|
||||
[isCollapsed, setSidebarWidth, router]
|
||||
)
|
||||
|
||||
const handleConfirmDeleteTasks = useCallback(() => {
|
||||
const { taskIds: taskIdsToDelete } = contextMenuSelectionRef.current
|
||||
if (taskIdsToDelete.length === 0) return
|
||||
@@ -648,7 +663,7 @@ export const Sidebar = memo(function Sidebar() {
|
||||
const onDeleteSuccess = () => {
|
||||
useFolderStore.getState().clearTaskSelection()
|
||||
if (isViewingDeletedTask) {
|
||||
router.push(`/workspace/${workspaceId}/home`)
|
||||
navigateToPage(`/workspace/${workspaceId}/home`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -658,7 +673,7 @@ export const Sidebar = memo(function Sidebar() {
|
||||
deleteTasksMutation.mutate(taskIdsToDelete, { onSuccess: onDeleteSuccess })
|
||||
}
|
||||
setIsTaskDeleteModalOpen(false)
|
||||
}, [pathname, workspaceId, deleteTaskMutation, deleteTasksMutation, router])
|
||||
}, [pathname, workspaceId, deleteTaskMutation, deleteTasksMutation, navigateToPage])
|
||||
|
||||
const [visibleTaskCount, setVisibleTaskCount] = useState(5)
|
||||
const [renamingTaskId, setRenamingTaskId] = useState<string | null>(null)
|
||||
@@ -910,7 +925,7 @@ export const Sidebar = memo(function Sidebar() {
|
||||
try {
|
||||
const pathWorkspaceId = resolveWorkspaceIdFromPath()
|
||||
if (pathWorkspaceId) {
|
||||
router.push(`/workspace/${pathWorkspaceId}/templates`)
|
||||
navigateToPage(`/workspace/${pathWorkspaceId}/templates`)
|
||||
logger.info('Navigated to templates', { workspaceId: pathWorkspaceId })
|
||||
} else {
|
||||
logger.warn('No workspace ID found, cannot navigate to templates')
|
||||
@@ -926,7 +941,7 @@ export const Sidebar = memo(function Sidebar() {
|
||||
try {
|
||||
const pathWorkspaceId = resolveWorkspaceIdFromPath()
|
||||
if (pathWorkspaceId) {
|
||||
router.push(`/workspace/${pathWorkspaceId}/logs`)
|
||||
navigateToPage(`/workspace/${pathWorkspaceId}/logs`)
|
||||
logger.info('Navigated to logs', { workspaceId: pathWorkspaceId })
|
||||
} else {
|
||||
logger.warn('No workspace ID found, cannot navigate to logs')
|
||||
@@ -1113,7 +1128,7 @@ export const Sidebar = memo(function Sidebar() {
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='h-[18px] w-[18px] rounded-[4px] p-0 hover:bg-[var(--surface-active)]'
|
||||
onClick={() => router.push(`/workspace/${workspaceId}/home`)}
|
||||
onClick={() => navigateToPage(`/workspace/${workspaceId}/home`)}
|
||||
>
|
||||
<Plus className='h-[16px] w-[16px]' />
|
||||
</Button>
|
||||
@@ -1131,7 +1146,7 @@ export const Sidebar = memo(function Sidebar() {
|
||||
<Blimp className='h-[16px] w-[16px] flex-shrink-0 text-[var(--text-icon)]' />
|
||||
}
|
||||
hover={tasksHover}
|
||||
onClick={() => router.push(`/workspace/${workspaceId}/home`)}
|
||||
onClick={() => navigateToPage(`/workspace/${workspaceId}/home`)}
|
||||
ariaLabel='Tasks'
|
||||
className='mt-[6px]'
|
||||
>
|
||||
|
||||
@@ -462,13 +462,25 @@ const Combobox = memo(
|
||||
[disabled, editable, inputRef]
|
||||
)
|
||||
|
||||
const effectiveHighlightedIndex =
|
||||
highlightedIndex >= 0 && highlightedIndex < filteredOptions.length ? highlightedIndex : -1
|
||||
|
||||
/**
|
||||
* Reset highlighted index when filtered options change and index is out of bounds
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (highlightedIndex >= 0 && highlightedIndex >= filteredOptions.length) {
|
||||
setHighlightedIndex(-1)
|
||||
}
|
||||
}, [filteredOptions, highlightedIndex])
|
||||
|
||||
/**
|
||||
* Scroll highlighted option into view
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (highlightedIndex >= 0 && dropdownRef.current) {
|
||||
if (effectiveHighlightedIndex >= 0 && dropdownRef.current) {
|
||||
const highlightedElement = dropdownRef.current.querySelector(
|
||||
`[data-option-index="${highlightedIndex}"]`
|
||||
`[data-option-index="${effectiveHighlightedIndex}"]`
|
||||
)
|
||||
if (highlightedElement) {
|
||||
highlightedElement.scrollIntoView({
|
||||
@@ -477,19 +489,7 @@ const Combobox = memo(
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [highlightedIndex])
|
||||
|
||||
/**
|
||||
* Adjust highlighted index when filtered options change
|
||||
*/
|
||||
useEffect(() => {
|
||||
setHighlightedIndex((prev) => {
|
||||
if (prev >= 0 && prev < filteredOptions.length) {
|
||||
return prev
|
||||
}
|
||||
return -1
|
||||
})
|
||||
}, [filteredOptions])
|
||||
}, [effectiveHighlightedIndex])
|
||||
|
||||
const SelectedIcon = selectedOption?.icon
|
||||
|
||||
@@ -713,7 +713,7 @@ const Combobox = memo(
|
||||
const globalIndex = filteredOptions.findIndex(
|
||||
(o) => o.value === option.value
|
||||
)
|
||||
const isHighlighted = globalIndex === highlightedIndex
|
||||
const isHighlighted = globalIndex === effectiveHighlightedIndex
|
||||
const OptionIcon = option.icon
|
||||
|
||||
return (
|
||||
@@ -789,7 +789,7 @@ const Combobox = memo(
|
||||
const isSelected = multiSelect
|
||||
? multiSelectValues?.includes(option.value)
|
||||
: effectiveSelectedValue === option.value
|
||||
const isHighlighted = index === highlightedIndex
|
||||
const isHighlighted = index === effectiveHighlightedIndex
|
||||
const OptionIcon = option.icon
|
||||
|
||||
return (
|
||||
|
||||
@@ -559,12 +559,15 @@ const DatePicker = React.forwardRef<HTMLDivElement, DatePickerProps>((props, ref
|
||||
}
|
||||
}, [open, isRangeMode, initialStart, initialEnd])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isRangeMode && selectedDate) {
|
||||
const singleValueKey = !isRangeMode && selectedDate ? selectedDate.getTime() : undefined
|
||||
const [prevSingleValueKey, setPrevSingleValueKey] = React.useState(singleValueKey)
|
||||
if (singleValueKey !== prevSingleValueKey) {
|
||||
setPrevSingleValueKey(singleValueKey)
|
||||
if (selectedDate) {
|
||||
setViewMonth(selectedDate.getMonth())
|
||||
setViewYear(selectedDate.getFullYear())
|
||||
}
|
||||
}, [isRangeMode, selectedDate])
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles selection of a specific day in single mode.
|
||||
|
||||
@@ -226,6 +226,7 @@ const Popover: React.FC<PopoverProps> = ({
|
||||
size = 'md',
|
||||
colorScheme = 'default',
|
||||
open,
|
||||
onOpenChange,
|
||||
...props
|
||||
}) => {
|
||||
const [currentFolder, setCurrentFolder] = React.useState<string | null>(null)
|
||||
@@ -251,21 +252,33 @@ const Popover: React.FC<PopoverProps> = ({
|
||||
}
|
||||
}, [])
|
||||
|
||||
/** Resets all navigation state to initial values */
|
||||
const resetState = React.useCallback(() => {
|
||||
setCurrentFolder(null)
|
||||
setFolderTitle(null)
|
||||
setOnFolderSelect(null)
|
||||
setSearchQuery('')
|
||||
setLastHoveredItem(null)
|
||||
setIsKeyboardNav(false)
|
||||
setSelectedIndex(-1)
|
||||
registeredItemsRef.current = []
|
||||
}, [])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open === false) {
|
||||
setCurrentFolder(null)
|
||||
setFolderTitle(null)
|
||||
setOnFolderSelect(null)
|
||||
setSearchQuery('')
|
||||
setLastHoveredItem(null)
|
||||
setIsKeyboardNav(false)
|
||||
setSelectedIndex(-1)
|
||||
registeredItemsRef.current = []
|
||||
} else {
|
||||
// Reset hover state when opening to prevent stale submenu from previous menu
|
||||
setLastHoveredItem(null)
|
||||
if (!open) {
|
||||
resetState()
|
||||
}
|
||||
}, [open])
|
||||
}, [open, resetState])
|
||||
|
||||
const handleOpenChange = React.useCallback(
|
||||
(nextOpen: boolean) => {
|
||||
if (nextOpen) {
|
||||
setLastHoveredItem(null)
|
||||
}
|
||||
onOpenChange?.(nextOpen)
|
||||
},
|
||||
[onOpenChange]
|
||||
)
|
||||
|
||||
const openFolder = React.useCallback(
|
||||
(id: string, title: string, onLoad?: () => void | Promise<void>, onSelect?: () => void) => {
|
||||
@@ -336,7 +349,7 @@ const Popover: React.FC<PopoverProps> = ({
|
||||
|
||||
return (
|
||||
<PopoverContext.Provider value={contextValue}>
|
||||
<PopoverPrimitive.Root open={open} {...props}>
|
||||
<PopoverPrimitive.Root open={open} onOpenChange={handleOpenChange} {...props}>
|
||||
{children}
|
||||
</PopoverPrimitive.Root>
|
||||
</PopoverContext.Provider>
|
||||
|
||||
@@ -135,13 +135,15 @@ const TimePicker = React.forwardRef<HTMLDivElement, TimePickerProps>(
|
||||
const [hour, setHour] = React.useState(parsed.hour)
|
||||
const [minute, setMinute] = React.useState(parsed.minute)
|
||||
const [ampm, setAmpm] = React.useState<'AM' | 'PM'>(parsed.ampm)
|
||||
const [prevValue, setPrevValue] = React.useState(value)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (value !== prevValue) {
|
||||
setPrevValue(value)
|
||||
const newParsed = parseTime(value || '')
|
||||
setHour(newParsed.hour)
|
||||
setMinute(newParsed.minute)
|
||||
setAmpm(newParsed.ampm)
|
||||
}, [value])
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
|
||||
Reference in New Issue
Block a user