Skip to content

Commit 2146326

Browse files
authored
feat(settings): added snap to grid slider to settings (#2504)
* feat(settings): added snap to grid slider to settings * ack PR comments * ack PR comment
1 parent 1ddbac1 commit 2146326

File tree

12 files changed

+8584
-33
lines changed

12 files changed

+8584
-33
lines changed

apps/sim/app/api/users/me/settings/route.ts

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,9 @@ const SettingsSchema = z.object({
2626
showTrainingControls: z.boolean().optional(),
2727
superUserModeEnabled: z.boolean().optional(),
2828
errorNotificationsEnabled: z.boolean().optional(),
29+
snapToGridSize: z.number().min(0).max(50).optional(),
2930
})
3031

31-
// Default settings values
3232
const defaultSettings = {
3333
theme: 'system',
3434
autoConnect: true,
@@ -38,6 +38,7 @@ const defaultSettings = {
3838
showTrainingControls: false,
3939
superUserModeEnabled: false,
4040
errorNotificationsEnabled: true,
41+
snapToGridSize: 0,
4142
}
4243

4344
export async function GET() {
@@ -46,7 +47,6 @@ export async function GET() {
4647
try {
4748
const session = await getSession()
4849

49-
// Return default settings for unauthenticated users instead of 401 error
5050
if (!session?.user?.id) {
5151
logger.info(`[${requestId}] Returning default settings for unauthenticated user`)
5252
return NextResponse.json({ data: defaultSettings }, { status: 200 })
@@ -72,13 +72,13 @@ export async function GET() {
7272
showTrainingControls: userSettings.showTrainingControls ?? false,
7373
superUserModeEnabled: userSettings.superUserModeEnabled ?? true,
7474
errorNotificationsEnabled: userSettings.errorNotificationsEnabled ?? true,
75+
snapToGridSize: userSettings.snapToGridSize ?? 0,
7576
},
7677
},
7778
{ status: 200 }
7879
)
7980
} catch (error: any) {
8081
logger.error(`[${requestId}] Settings fetch error`, error)
81-
// Return default settings on error instead of error response
8282
return NextResponse.json({ data: defaultSettings }, { status: 200 })
8383
}
8484
}
@@ -89,7 +89,6 @@ export async function PATCH(request: Request) {
8989
try {
9090
const session = await getSession()
9191

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

106-
// Store the settings
107105
await db
108106
.insert(settings)
109107
.values({
@@ -135,7 +133,6 @@ export async function PATCH(request: Request) {
135133
}
136134
} catch (error: any) {
137135
logger.error(`[${requestId}] Settings update error`, error)
138-
// Return success on error instead of error response
139136
return NextResponse.json({ success: true }, { status: 200 })
140137
}
141138
}

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,6 @@ const edgeTypes: EdgeTypes = {
9292

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

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

162+
// Snap to grid settings
163+
const snapToGridSize = useGeneralStore((state) => state.snapToGridSize)
164+
const snapToGrid = snapToGridSize > 0
165+
const snapGrid: [number, number] = useMemo(
166+
() => [snapToGridSize, snapToGridSize],
167+
[snapToGridSize]
168+
)
169+
163170
// Handle copilot stream cleanup on page unload and component unmount
164171
useStreamCleanup(copilotCleanup)
165172

@@ -2311,7 +2318,7 @@ const WorkflowContent = React.memo(() => {
23112318
onNodeDrag={effectivePermissions.canEdit ? onNodeDrag : undefined}
23122319
onNodeDragStop={effectivePermissions.canEdit ? onNodeDragStop : undefined}
23132320
onNodeDragStart={effectivePermissions.canEdit ? onNodeDragStart : undefined}
2314-
snapToGrid={false}
2321+
snapToGrid={snapToGrid}
23152322
snapGrid={snapGrid}
23162323
elevateEdgesOnSelect={true}
23172324
onlyRenderVisibleElements={false}

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/general/general.tsx

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
ModalContent,
1313
ModalFooter,
1414
ModalHeader,
15+
Slider,
1516
Switch,
1617
} from '@/components/emcn'
1718
import { Input, Skeleton } from '@/components/ui'
@@ -76,6 +77,9 @@ export function General({ onOpenChange }: GeneralProps) {
7677

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

80+
const [localSnapValue, setLocalSnapValue] = useState<number | null>(null)
81+
const snapToGridValue = localSnapValue ?? settings?.snapToGridSize ?? 0
82+
7983
useEffect(() => {
8084
if (profile?.name) {
8185
setName(profile.name)
@@ -234,6 +238,18 @@ export function General({ onOpenChange }: GeneralProps) {
234238
}
235239
}
236240

241+
const handleSnapToGridChange = (value: number[]) => {
242+
setLocalSnapValue(value[0])
243+
}
244+
245+
const handleSnapToGridCommit = async (value: number[]) => {
246+
const newValue = value[0]
247+
if (newValue !== settings?.snapToGridSize && !updateSetting.isPending) {
248+
await updateSetting.mutateAsync({ key: 'snapToGridSize', value: newValue })
249+
}
250+
setLocalSnapValue(null)
251+
}
252+
237253
const handleTrainingControlsChange = async (checked: boolean) => {
238254
if (checked !== settings?.showTrainingControls && !updateSetting.isPending) {
239255
await updateSetting.mutateAsync({ key: 'showTrainingControls', value: checked })
@@ -393,7 +409,6 @@ export function General({ onOpenChange }: GeneralProps) {
393409
dropdownWidth={140}
394410
value={settings?.theme}
395411
onChange={handleThemeChange}
396-
disabled={updateSetting.isPending}
397412
placeholder='Select theme'
398413
options={[
399414
{ label: 'System', value: 'system' },
@@ -410,17 +425,34 @@ export function General({ onOpenChange }: GeneralProps) {
410425
id='auto-connect'
411426
checked={settings?.autoConnect ?? true}
412427
onCheckedChange={handleAutoConnectChange}
413-
disabled={updateSetting.isPending}
414428
/>
415429
</div>
416430

431+
<div className='flex items-center justify-between'>
432+
<Label htmlFor='snap-to-grid'>Snap to grid</Label>
433+
<div className='flex items-center gap-[12px]'>
434+
<span className='w-[32px] text-right text-[12px] text-[var(--text-tertiary)]'>
435+
{snapToGridValue === 0 ? 'Off' : `${snapToGridValue}px`}
436+
</span>
437+
<Slider
438+
id='snap-to-grid'
439+
value={[snapToGridValue]}
440+
onValueChange={handleSnapToGridChange}
441+
onValueCommit={handleSnapToGridCommit}
442+
min={0}
443+
max={50}
444+
step={1}
445+
className='w-[100px]'
446+
/>
447+
</div>
448+
</div>
449+
417450
<div className='flex items-center justify-between'>
418451
<Label htmlFor='error-notifications'>Run error notifications</Label>
419452
<Switch
420453
id='error-notifications'
421454
checked={settings?.errorNotificationsEnabled ?? true}
422455
onCheckedChange={handleErrorNotificationsChange}
423-
disabled={updateSetting.isPending}
424456
/>
425457
</div>
426458

@@ -430,7 +462,6 @@ export function General({ onOpenChange }: GeneralProps) {
430462
id='telemetry'
431463
checked={settings?.telemetryEnabled ?? true}
432464
onCheckedChange={handleTelemetryToggle}
433-
disabled={updateSetting.isPending}
434465
/>
435466
</div>
436467

@@ -446,7 +477,6 @@ export function General({ onOpenChange }: GeneralProps) {
446477
id='training-controls'
447478
checked={settings?.showTrainingControls ?? false}
448479
onCheckedChange={handleTrainingControlsChange}
449-
disabled={updateSetting.isPending}
450480
/>
451481
</div>
452482
)}
@@ -458,7 +488,6 @@ export function General({ onOpenChange }: GeneralProps) {
458488
id='super-user-mode'
459489
checked={settings?.superUserModeEnabled ?? true}
460490
onCheckedChange={handleSuperUserModeToggle}
461-
disabled={updateSetting.isPending}
462491
/>
463492
</div>
464493
)}
@@ -534,6 +563,15 @@ function GeneralSkeleton() {
534563
<Skeleton className='h-[17px] w-[30px] rounded-full' />
535564
</div>
536565

566+
{/* Snap to grid row */}
567+
<div className='flex items-center justify-between'>
568+
<Skeleton className='h-4 w-24' />
569+
<div className='flex items-center gap-[12px]'>
570+
<Skeleton className='h-3 w-[32px]' />
571+
<Skeleton className='h-[6px] w-[100px] rounded-[20px]' />
572+
</div>
573+
</div>
574+
537575
{/* Error notifications row */}
538576
<div className='flex items-center justify-between'>
539577
<Skeleton className='h-4 w-40' />

apps/sim/components/emcn/components/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ export {
6767
SModalSidebarSectionTitle,
6868
SModalTrigger,
6969
} from './s-modal/s-modal'
70+
export { Slider, type SliderProps } from './slider/slider'
7071
export { Switch } from './switch/switch'
7172
export { Textarea } from './textarea/textarea'
7273
export { Tooltip } from './tooltip/tooltip'
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
'use client'
2+
3+
import * as React from 'react'
4+
import * as SliderPrimitive from '@radix-ui/react-slider'
5+
import { cn } from '@/lib/core/utils/cn'
6+
7+
export interface SliderProps extends React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root> {}
8+
9+
/**
10+
* EMCN Slider component built on Radix UI Slider primitive.
11+
* Styled to match the Switch component with thin track design.
12+
*
13+
* @example
14+
* ```tsx
15+
* <Slider value={[50]} onValueChange={setValue} min={0} max={100} step={10} />
16+
* ```
17+
*/
18+
const Slider = React.forwardRef<React.ElementRef<typeof SliderPrimitive.Root>, SliderProps>(
19+
({ className, ...props }, ref) => (
20+
<SliderPrimitive.Root
21+
ref={ref}
22+
className={cn(
23+
'relative flex w-full touch-none select-none items-center',
24+
'disabled:cursor-not-allowed disabled:opacity-50',
25+
className
26+
)}
27+
{...props}
28+
>
29+
<SliderPrimitive.Track className='relative h-[6px] w-full grow overflow-hidden rounded-[20px] bg-[var(--surface-12)] transition-colors'>
30+
<SliderPrimitive.Range className='absolute h-full bg-[var(--surface-12)]' />
31+
</SliderPrimitive.Track>
32+
<SliderPrimitive.Thumb className='block h-[14px] w-[14px] cursor-pointer rounded-full bg-[var(--text-primary)] shadow-sm transition-colors focus-visible:outline-none' />
33+
</SliderPrimitive.Root>
34+
)
35+
)
36+
37+
Slider.displayName = SliderPrimitive.Root.displayName
38+
39+
export { Slider }

apps/sim/hooks/queries/general-settings.ts

Lines changed: 23 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { useEffect } from 'react'
21
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
32
import { syncThemeToNextThemes } from '@/lib/core/utils/theme'
43
import { createLogger } from '@/lib/logs/console/logger'
@@ -25,6 +24,7 @@ export interface GeneralSettings {
2524
telemetryEnabled: boolean
2625
billingUsageNotificationsEnabled: boolean
2726
errorNotificationsEnabled: boolean
27+
snapToGridSize: number
2828
}
2929

3030
/**
@@ -49,49 +49,56 @@ async function fetchGeneralSettings(): Promise<GeneralSettings> {
4949
telemetryEnabled: data.telemetryEnabled ?? true,
5050
billingUsageNotificationsEnabled: data.billingUsageNotificationsEnabled ?? true,
5151
errorNotificationsEnabled: data.errorNotificationsEnabled ?? true,
52+
snapToGridSize: data.snapToGridSize ?? 0,
5253
}
5354
}
5455

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

63-
setSettings({
65+
const newSettings = {
6466
isAutoConnectEnabled: settings.autoConnect,
6567
showTrainingControls: settings.showTrainingControls,
6668
superUserModeEnabled: settings.superUserModeEnabled,
6769
theme: settings.theme,
6870
telemetryEnabled: settings.telemetryEnabled,
6971
isBillingUsageNotificationsEnabled: settings.billingUsageNotificationsEnabled,
7072
isErrorNotificationsEnabled: settings.errorNotificationsEnabled,
71-
})
73+
snapToGridSize: settings.snapToGridSize,
74+
}
75+
76+
const hasChanges = Object.entries(newSettings).some(
77+
([key, value]) => store[key as keyof typeof newSettings] !== value
78+
)
79+
80+
if (hasChanges) {
81+
store.setSettings(newSettings)
82+
}
7283

7384
syncThemeToNextThemes(settings.theme)
7485
}
7586

7687
/**
7788
* Hook to fetch general settings.
78-
* Also syncs to Zustand store to keep the rest of the app in sync.
89+
* Syncs to Zustand store only on successful fetch (not on cache updates from mutations).
7990
*/
8091
export function useGeneralSettings() {
81-
const query = useQuery({
92+
return useQuery({
8293
queryKey: generalSettingsKeys.settings(),
83-
queryFn: fetchGeneralSettings,
94+
queryFn: async () => {
95+
const settings = await fetchGeneralSettings()
96+
syncSettingsToZustand(settings)
97+
return settings
98+
},
8499
staleTime: 60 * 60 * 1000,
85100
placeholderData: keepPreviousData,
86101
})
87-
88-
useEffect(() => {
89-
if (query.data) {
90-
syncSettingsToZustand(query.data)
91-
}
92-
}, [query.data])
93-
94-
return query
95102
}
96103

97104
/**
@@ -131,8 +138,8 @@ export function useUpdateGeneralSetting() {
131138
...previousSettings,
132139
[key]: value,
133140
}
134-
queryClient.setQueryData<GeneralSettings>(generalSettingsKeys.settings(), newSettings)
135141

142+
queryClient.setQueryData<GeneralSettings>(generalSettingsKeys.settings(), newSettings)
136143
syncSettingsToZustand(newSettings)
137144
}
138145

@@ -145,8 +152,5 @@ export function useUpdateGeneralSetting() {
145152
}
146153
logger.error('Failed to update setting:', err)
147154
},
148-
onSuccess: (_data, _variables, _context) => {
149-
queryClient.invalidateQueries({ queryKey: generalSettingsKeys.settings() })
150-
},
151155
})
152156
}

apps/sim/stores/settings/general/store.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ const initialState: General = {
1313
telemetryEnabled: true,
1414
isBillingUsageNotificationsEnabled: true,
1515
isErrorNotificationsEnabled: true,
16+
snapToGridSize: 0,
1617
}
1718

1819
export const useGeneralStore = create<GeneralStore>()(

apps/sim/stores/settings/general/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export interface General {
66
telemetryEnabled: boolean
77
isBillingUsageNotificationsEnabled: boolean
88
isErrorNotificationsEnabled: boolean
9+
snapToGridSize: number
910
}
1011

1112
export interface GeneralStore extends General {
@@ -21,4 +22,5 @@ export type UserSettings = {
2122
telemetryEnabled: boolean
2223
isBillingUsageNotificationsEnabled: boolean
2324
errorNotificationsEnabled: boolean
25+
snapToGridSize: number
2426
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
ALTER TABLE "settings" ADD COLUMN "snap_to_grid_size" integer DEFAULT 0 NOT NULL;

0 commit comments

Comments
 (0)