mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-07 22:24:06 -05:00
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
This commit is contained in:
@@ -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,
|
||||
@@ -38,6 +38,7 @@ const defaultSettings = {
|
||||
showTrainingControls: false,
|
||||
superUserModeEnabled: false,
|
||||
errorNotificationsEnabled: true,
|
||||
snapToGridSize: 0,
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
@@ -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 })
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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`
|
||||
@@ -103,7 +102,6 @@ export async function PATCH(request: Request) {
|
||||
try {
|
||||
const validatedData = SettingsSchema.parse(body)
|
||||
|
||||
// Store the settings
|
||||
await db
|
||||
.insert(settings)
|
||||
.values({
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
Slider,
|
||||
Switch,
|
||||
} from '@/components/emcn'
|
||||
import { Input, Skeleton } from '@/components/ui'
|
||||
@@ -76,6 +77,9 @@ export function General({ onOpenChange }: GeneralProps) {
|
||||
|
||||
const [uploadError, setUploadError] = useState<string | null>(null)
|
||||
|
||||
const [localSnapValue, setLocalSnapValue] = useState<number | null>(null)
|
||||
const snapToGridValue = localSnapValue ?? settings?.snapToGridSize ?? 0
|
||||
|
||||
useEffect(() => {
|
||||
if (profile?.name) {
|
||||
setName(profile.name)
|
||||
@@ -234,6 +238,18 @@ export function General({ onOpenChange }: GeneralProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const handleSnapToGridChange = (value: number[]) => {
|
||||
setLocalSnapValue(value[0])
|
||||
}
|
||||
|
||||
const handleSnapToGridCommit = async (value: number[]) => {
|
||||
const newValue = value[0]
|
||||
if (newValue !== settings?.snapToGridSize && !updateSetting.isPending) {
|
||||
await updateSetting.mutateAsync({ key: 'snapToGridSize', value: newValue })
|
||||
}
|
||||
setLocalSnapValue(null)
|
||||
}
|
||||
|
||||
const handleTrainingControlsChange = async (checked: boolean) => {
|
||||
if (checked !== settings?.showTrainingControls && !updateSetting.isPending) {
|
||||
await updateSetting.mutateAsync({ key: 'showTrainingControls', value: checked })
|
||||
@@ -393,7 +409,6 @@ export function General({ onOpenChange }: GeneralProps) {
|
||||
dropdownWidth={140}
|
||||
value={settings?.theme}
|
||||
onChange={handleThemeChange}
|
||||
disabled={updateSetting.isPending}
|
||||
placeholder='Select theme'
|
||||
options={[
|
||||
{ label: 'System', value: 'system' },
|
||||
@@ -410,17 +425,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>
|
||||
|
||||
@@ -430,7 +462,6 @@ export function General({ onOpenChange }: GeneralProps) {
|
||||
id='telemetry'
|
||||
checked={settings?.telemetryEnabled ?? true}
|
||||
onCheckedChange={handleTelemetryToggle}
|
||||
disabled={updateSetting.isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -446,7 +477,6 @@ export function General({ onOpenChange }: GeneralProps) {
|
||||
id='training-controls'
|
||||
checked={settings?.showTrainingControls ?? false}
|
||||
onCheckedChange={handleTrainingControlsChange}
|
||||
disabled={updateSetting.isPending}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -458,7 +488,6 @@ export function General({ onOpenChange }: GeneralProps) {
|
||||
id='super-user-mode'
|
||||
checked={settings?.superUserModeEnabled ?? true}
|
||||
onCheckedChange={handleSuperUserModeToggle}
|
||||
disabled={updateSetting.isPending}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -534,6 +563,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' />
|
||||
|
||||
@@ -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
apps/sim/components/emcn/components/slider/slider.tsx
Normal file
39
apps/sim/components/emcn/components/slider/slider.tsx
Normal file
@@ -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 }
|
||||
@@ -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'
|
||||
@@ -25,6 +24,7 @@ export interface GeneralSettings {
|
||||
telemetryEnabled: boolean
|
||||
billingUsageNotificationsEnabled: boolean
|
||||
errorNotificationsEnabled: boolean
|
||||
snapToGridSize: number
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -49,18 +49,20 @@ 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,
|
||||
@@ -68,30 +70,35 @@ function syncSettingsToZustand(settings: GeneralSettings) {
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -131,8 +138,8 @@ export function useUpdateGeneralSetting() {
|
||||
...previousSettings,
|
||||
[key]: value,
|
||||
}
|
||||
queryClient.setQueryData<GeneralSettings>(generalSettingsKeys.settings(), newSettings)
|
||||
|
||||
queryClient.setQueryData<GeneralSettings>(generalSettingsKeys.settings(), newSettings)
|
||||
syncSettingsToZustand(newSettings)
|
||||
}
|
||||
|
||||
@@ -145,8 +152,5 @@ export function useUpdateGeneralSetting() {
|
||||
}
|
||||
logger.error('Failed to update setting:', err)
|
||||
},
|
||||
onSuccess: (_data, _variables, _context) => {
|
||||
queryClient.invalidateQueries({ queryKey: generalSettingsKeys.settings() })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ const initialState: General = {
|
||||
telemetryEnabled: true,
|
||||
isBillingUsageNotificationsEnabled: true,
|
||||
isErrorNotificationsEnabled: true,
|
||||
snapToGridSize: 0,
|
||||
}
|
||||
|
||||
export const useGeneralStore = create<GeneralStore>()(
|
||||
|
||||
@@ -6,6 +6,7 @@ export interface General {
|
||||
telemetryEnabled: boolean
|
||||
isBillingUsageNotificationsEnabled: boolean
|
||||
isErrorNotificationsEnabled: boolean
|
||||
snapToGridSize: number
|
||||
}
|
||||
|
||||
export interface GeneralStore extends General {
|
||||
@@ -21,4 +22,5 @@ export type UserSettings = {
|
||||
telemetryEnabled: boolean
|
||||
isBillingUsageNotificationsEnabled: boolean
|
||||
errorNotificationsEnabled: boolean
|
||||
snapToGridSize: number
|
||||
}
|
||||
|
||||
1
packages/db/migrations/0129_stormy_nightmare.sql
Normal file
1
packages/db/migrations/0129_stormy_nightmare.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "settings" ADD COLUMN "snap_to_grid_size" integer DEFAULT 0 NOT NULL;
|
||||
8451
packages/db/migrations/meta/0129_snapshot.json
Normal file
8451
packages/db/migrations/meta/0129_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -897,6 +897,13 @@
|
||||
"when": 1766266581373,
|
||||
"tag": "0128_swift_terrax",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 129,
|
||||
"version": "7",
|
||||
"when": 1766275541149,
|
||||
"tag": "0129_stormy_nightmare",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -448,6 +448,9 @@ export const settings = pgTable('settings', {
|
||||
// Notification preferences
|
||||
errorNotificationsEnabled: boolean('error_notifications_enabled').notNull().default(true),
|
||||
|
||||
// Canvas preferences
|
||||
snapToGridSize: integer('snap_to_grid_size').notNull().default(0), // 0 = off, 10-50 = grid size
|
||||
|
||||
// Copilot preferences - maps model_id to enabled/disabled boolean
|
||||
copilotEnabledModels: jsonb('copilot_enabled_models').notNull().default('{}'),
|
||||
|
||||
|
||||
Reference in New Issue
Block a user