Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
9 changes: 3 additions & 6 deletions apps/sim/app/api/users/me/settings/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,9 @@ const SettingsSchema = z.object({
showTrainingControls: z.boolean().optional(),
superUserModeEnabled: z.boolean().optional(),
errorNotificationsEnabled: z.boolean().optional(),
snapToGridSize: z.number().min(0).max(50).optional(),
})

// Default settings values
const defaultSettings = {
theme: 'system',
autoConnect: true,
Expand All @@ -38,6 +38,7 @@ const defaultSettings = {
showTrainingControls: false,
superUserModeEnabled: false,
errorNotificationsEnabled: true,
snapToGridSize: 0,
}

export async function GET() {
Expand All @@ -46,7 +47,6 @@ export async function GET() {
try {
const session = await getSession()

// Return default settings for unauthenticated users instead of 401 error
if (!session?.user?.id) {
logger.info(`[${requestId}] Returning default settings for unauthenticated user`)
return NextResponse.json({ data: defaultSettings }, { status: 200 })
Expand All @@ -72,13 +72,13 @@ export async function GET() {
showTrainingControls: userSettings.showTrainingControls ?? false,
superUserModeEnabled: userSettings.superUserModeEnabled ?? true,
errorNotificationsEnabled: userSettings.errorNotificationsEnabled ?? true,
snapToGridSize: userSettings.snapToGridSize ?? 0,
},
},
{ status: 200 }
)
} catch (error: any) {
logger.error(`[${requestId}] Settings fetch error`, error)
// Return default settings on error instead of error response
return NextResponse.json({ data: defaultSettings }, { status: 200 })
}
}
Expand All @@ -89,7 +89,6 @@ export async function PATCH(request: Request) {
try {
const session = await getSession()

// Return success for unauthenticated users instead of error
if (!session?.user?.id) {
logger.info(
`[${requestId}] Settings update attempted by unauthenticated user - acknowledged without saving`
Expand All @@ -103,7 +102,6 @@ export async function PATCH(request: Request) {
try {
const validatedData = SettingsSchema.parse(body)

// Store the settings
await db
.insert(settings)
.values({
Expand Down Expand Up @@ -135,7 +133,6 @@ export async function PATCH(request: Request) {
}
} catch (error: any) {
logger.error(`[${requestId}] Settings update error`, error)
// Return success on error instead of error response
return NextResponse.json({ success: true }, { status: 200 })
}
}
11 changes: 9 additions & 2 deletions apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,6 @@ const edgeTypes: EdgeTypes = {

/** ReactFlow configuration constants. */
const defaultEdgeOptions = { type: 'custom' }
const snapGrid: [number, number] = [20, 20]
const reactFlowFitViewOptions = { padding: 0.6 } as const
const reactFlowProOptions = { hideAttribution: true } as const

Expand Down Expand Up @@ -160,6 +159,14 @@ const WorkflowContent = React.memo(() => {
// Training modal state
const showTrainingModal = useCopilotTrainingStore((state) => state.showModal)

// Snap to grid settings
const snapToGridSize = useGeneralStore((state) => state.snapToGridSize)
const snapToGrid = snapToGridSize > 0
const snapGrid: [number, number] = useMemo(
() => [snapToGridSize, snapToGridSize],
[snapToGridSize]
)

// Handle copilot stream cleanup on page unload and component unmount
useStreamCleanup(copilotCleanup)

Expand Down Expand Up @@ -2311,7 +2318,7 @@ const WorkflowContent = React.memo(() => {
onNodeDrag={effectivePermissions.canEdit ? onNodeDrag : undefined}
onNodeDragStop={effectivePermissions.canEdit ? onNodeDragStop : undefined}
onNodeDragStart={effectivePermissions.canEdit ? onNodeDragStart : undefined}
snapToGrid={false}
snapToGrid={snapToGrid}
snapGrid={snapGrid}
elevateEdgesOnSelect={true}
onlyRenderVisibleElements={false}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
ModalContent,
ModalFooter,
ModalHeader,
Slider,
Switch,
} from '@/components/emcn'
import { Input, Skeleton } from '@/components/ui'
Expand Down Expand Up @@ -76,12 +77,18 @@ export function General({ onOpenChange }: GeneralProps) {

const [uploadError, setUploadError] = useState<string | null>(null)

const [snapToGridValue, setSnapToGridValue] = useState(settings?.snapToGridSize ?? 0)

useEffect(() => {
if (profile?.name) {
setName(profile.name)
}
}, [profile?.name])

useEffect(() => {
setSnapToGridValue(settings?.snapToGridSize ?? 0)
}, [settings?.snapToGridSize])

useEffect(() => {
const fetchSuperUserStatus = async () => {
try {
Expand Down Expand Up @@ -234,6 +241,17 @@ export function General({ onOpenChange }: GeneralProps) {
}
}

const handleSnapToGridChange = (value: number[]) => {
setSnapToGridValue(value[0])
}

const handleSnapToGridCommit = async (value: number[]) => {
const newValue = value[0]
if (newValue !== settings?.snapToGridSize && !updateSetting.isPending) {
await updateSetting.mutateAsync({ key: 'snapToGridSize', value: newValue })
}
}

const handleTrainingControlsChange = async (checked: boolean) => {
if (checked !== settings?.showTrainingControls && !updateSetting.isPending) {
await updateSetting.mutateAsync({ key: 'showTrainingControls', value: checked })
Expand Down Expand Up @@ -410,17 +428,34 @@ export function General({ onOpenChange }: GeneralProps) {
id='auto-connect'
checked={settings?.autoConnect ?? true}
onCheckedChange={handleAutoConnectChange}
disabled={updateSetting.isPending}
/>
</div>

<div className='flex items-center justify-between'>
<Label htmlFor='snap-to-grid'>Snap to grid</Label>
<div className='flex items-center gap-[12px]'>
<span className='w-[32px] text-right text-[12px] text-[var(--text-tertiary)]'>
{snapToGridValue === 0 ? 'Off' : `${snapToGridValue}px`}
</span>
<Slider
id='snap-to-grid'
value={[snapToGridValue]}
onValueChange={handleSnapToGridChange}
onValueCommit={handleSnapToGridCommit}
min={0}
max={50}
step={1}
className='w-[100px]'
/>
</div>
</div>

<div className='flex items-center justify-between'>
<Label htmlFor='error-notifications'>Run error notifications</Label>
<Switch
id='error-notifications'
checked={settings?.errorNotificationsEnabled ?? true}
onCheckedChange={handleErrorNotificationsChange}
disabled={updateSetting.isPending}
/>
</div>

Expand All @@ -430,7 +465,6 @@ export function General({ onOpenChange }: GeneralProps) {
id='telemetry'
checked={settings?.telemetryEnabled ?? true}
onCheckedChange={handleTelemetryToggle}
disabled={updateSetting.isPending}
/>
</div>

Expand All @@ -446,7 +480,6 @@ export function General({ onOpenChange }: GeneralProps) {
id='training-controls'
checked={settings?.showTrainingControls ?? false}
onCheckedChange={handleTrainingControlsChange}
disabled={updateSetting.isPending}
/>
</div>
)}
Expand All @@ -458,7 +491,6 @@ export function General({ onOpenChange }: GeneralProps) {
id='super-user-mode'
checked={settings?.superUserModeEnabled ?? true}
onCheckedChange={handleSuperUserModeToggle}
disabled={updateSetting.isPending}
/>
</div>
)}
Expand Down Expand Up @@ -534,6 +566,15 @@ function GeneralSkeleton() {
<Skeleton className='h-[17px] w-[30px] rounded-full' />
</div>

{/* Snap to grid row */}
<div className='flex items-center justify-between'>
<Skeleton className='h-4 w-24' />
<div className='flex items-center gap-[12px]'>
<Skeleton className='h-3 w-[32px]' />
<Skeleton className='h-[6px] w-[100px] rounded-[20px]' />
</div>
</div>

{/* Error notifications row */}
<div className='flex items-center justify-between'>
<Skeleton className='h-4 w-40' />
Expand Down
1 change: 1 addition & 0 deletions apps/sim/components/emcn/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ export {
SModalSidebarSectionTitle,
SModalTrigger,
} from './s-modal/s-modal'
export { Slider, type SliderProps } from './slider/slider'
export { Switch } from './switch/switch'
export { Textarea } from './textarea/textarea'
export { Tooltip } from './tooltip/tooltip'
39 changes: 39 additions & 0 deletions apps/sim/components/emcn/components/slider/slider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
'use client'

import * as React from 'react'
import * as SliderPrimitive from '@radix-ui/react-slider'
import { cn } from '@/lib/core/utils/cn'

export interface SliderProps extends React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root> {}

/**
* EMCN Slider component built on Radix UI Slider primitive.
* Styled to match the Switch component with thin track design.
*
* @example
* ```tsx
* <Slider value={[50]} onValueChange={setValue} min={0} max={100} step={10} />
* ```
*/
const Slider = React.forwardRef<React.ElementRef<typeof SliderPrimitive.Root>, SliderProps>(
({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn(
'relative flex w-full touch-none select-none items-center',
'disabled:cursor-not-allowed disabled:opacity-50',
className
)}
{...props}
>
<SliderPrimitive.Track className='relative h-[6px] w-full grow overflow-hidden rounded-[20px] bg-[var(--surface-12)] transition-colors'>
<SliderPrimitive.Range className='absolute h-full bg-[var(--surface-12)]' />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className='block h-[14px] w-[14px] cursor-pointer rounded-full bg-[var(--text-primary)] shadow-sm transition-colors focus-visible:outline-none' />
</SliderPrimitive.Root>
)
)

Slider.displayName = SliderPrimitive.Root.displayName

export { Slider }
42 changes: 23 additions & 19 deletions apps/sim/hooks/queries/general-settings.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { useEffect } from 'react'
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { syncThemeToNextThemes } from '@/lib/core/utils/theme'
import { createLogger } from '@/lib/logs/console/logger'
Expand All @@ -25,6 +24,7 @@ export interface GeneralSettings {
telemetryEnabled: boolean
billingUsageNotificationsEnabled: boolean
errorNotificationsEnabled: boolean
snapToGridSize: number
}

/**
Expand All @@ -49,49 +49,56 @@ async function fetchGeneralSettings(): Promise<GeneralSettings> {
telemetryEnabled: data.telemetryEnabled ?? true,
billingUsageNotificationsEnabled: data.billingUsageNotificationsEnabled ?? true,
errorNotificationsEnabled: data.errorNotificationsEnabled ?? true,
snapToGridSize: data.snapToGridSize ?? 0,
}
}

/**
* Sync React Query cache to Zustand store and next-themes.
* This ensures the rest of the app (which uses Zustand) stays in sync.
* Uses shallow comparison to prevent unnecessary updates and flickering.
* @param settings - The general settings to sync
*/
function syncSettingsToZustand(settings: GeneralSettings) {
const { setSettings } = useGeneralStore.getState()
const store = useGeneralStore.getState()

setSettings({
const newSettings = {
isAutoConnectEnabled: settings.autoConnect,
showTrainingControls: settings.showTrainingControls,
superUserModeEnabled: settings.superUserModeEnabled,
theme: settings.theme,
telemetryEnabled: settings.telemetryEnabled,
isBillingUsageNotificationsEnabled: settings.billingUsageNotificationsEnabled,
isErrorNotificationsEnabled: settings.errorNotificationsEnabled,
})
snapToGridSize: settings.snapToGridSize,
}

const hasChanges = Object.entries(newSettings).some(
([key, value]) => store[key as keyof typeof newSettings] !== value
)

if (hasChanges) {
store.setSettings(newSettings)
}

syncThemeToNextThemes(settings.theme)
}

/**
* Hook to fetch general settings.
* Also syncs to Zustand store to keep the rest of the app in sync.
* Syncs to Zustand store only on successful fetch (not on cache updates from mutations).
*/
export function useGeneralSettings() {
const query = useQuery({
return useQuery({
queryKey: generalSettingsKeys.settings(),
queryFn: fetchGeneralSettings,
queryFn: async () => {
const settings = await fetchGeneralSettings()
syncSettingsToZustand(settings)
return settings
},
staleTime: 60 * 60 * 1000,
placeholderData: keepPreviousData,
})

useEffect(() => {
if (query.data) {
syncSettingsToZustand(query.data)
}
}, [query.data])

return query
}

/**
Expand Down Expand Up @@ -131,8 +138,8 @@ export function useUpdateGeneralSetting() {
...previousSettings,
[key]: value,
}
queryClient.setQueryData<GeneralSettings>(generalSettingsKeys.settings(), newSettings)

queryClient.setQueryData<GeneralSettings>(generalSettingsKeys.settings(), newSettings)
syncSettingsToZustand(newSettings)
}

Expand All @@ -145,8 +152,5 @@ export function useUpdateGeneralSetting() {
}
logger.error('Failed to update setting:', err)
},
onSuccess: (_data, _variables, _context) => {
queryClient.invalidateQueries({ queryKey: generalSettingsKeys.settings() })
},
})
}
1 change: 1 addition & 0 deletions apps/sim/stores/settings/general/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const initialState: General = {
telemetryEnabled: true,
isBillingUsageNotificationsEnabled: true,
isErrorNotificationsEnabled: true,
snapToGridSize: 0,
}

export const useGeneralStore = create<GeneralStore>()(
Expand Down
2 changes: 2 additions & 0 deletions apps/sim/stores/settings/general/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export interface General {
telemetryEnabled: boolean
isBillingUsageNotificationsEnabled: boolean
isErrorNotificationsEnabled: boolean
snapToGridSize: number
}

export interface GeneralStore extends General {
Expand All @@ -21,4 +22,5 @@ export type UserSettings = {
telemetryEnabled: boolean
isBillingUsageNotificationsEnabled: boolean
errorNotificationsEnabled: boolean
snapToGridSize: number
}
1 change: 1 addition & 0 deletions packages/db/migrations/0129_stormy_nightmare.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE "settings" ADD COLUMN "snap_to_grid_size" integer DEFAULT 0 NOT NULL;
Loading
Loading