Skip to content

Commit 9ef739e

Browse files
committed
feat: add type support to settings and implement dynamic form handling for global settings
1 parent db3f61e commit 9ef739e

File tree

4 files changed

+176
-85
lines changed

4 files changed

+176
-85
lines changed

services/frontend/src/components/settings/GlobalSettingsSidebarNav.vue

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button' // Adjusted path assuming shadcn
77
export interface Setting {
88
key: string
99
value: any
10+
type: 'string' | 'number' | 'boolean'
1011
description?: string
1112
is_encrypted?: boolean
1213
group_id?: string
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
<script setup lang="ts">
2+
import type { HTMLAttributes } from 'vue'
3+
import { reactiveOmit } from '@vueuse/core'
4+
import {
5+
SwitchRoot,
6+
type SwitchRootEmits,
7+
type SwitchRootProps,
8+
SwitchThumb,
9+
useForwardPropsEmits,
10+
} from 'reka-ui'
11+
import { cn } from '@/lib/utils'
12+
13+
const props = defineProps<SwitchRootProps & { class?: HTMLAttributes['class'] }>()
14+
15+
const emits = defineEmits<SwitchRootEmits>()
16+
17+
const delegatedProps = reactiveOmit(props, 'class')
18+
19+
const forwarded = useForwardPropsEmits(delegatedProps, emits)
20+
</script>
21+
22+
<template>
23+
<SwitchRoot
24+
data-slot="switch"
25+
v-bind="forwarded"
26+
:class="cn(
27+
'peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
28+
props.class,
29+
)"
30+
>
31+
<SwitchThumb
32+
data-slot="switch-thumb"
33+
:class="cn('bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0')"
34+
>
35+
<slot name="thumb" />
36+
</SwitchThumb>
37+
</SwitchRoot>
38+
</template>
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { default as Switch } from './Switch.vue'

services/frontend/src/views/GlobalSettings.vue

Lines changed: 136 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -85,79 +85,114 @@ const selectedGroup = computed(() => {
8585
import { type Setting } from '@/components/settings/GlobalSettingsSidebarNav.vue' // Import Setting interface
8686
import { Button } from '@/components/ui/button'
8787
import { Input } from '@/components/ui/input'
88-
import { Label } from '@/components/ui/label' // Using Label directly for now, can switch to Form components later
89-
// import { Switch } from '@/components/ui/switch' // For boolean settings later
90-
// VeeValidate and Zod for later, more complex validation
91-
// import { toTypedSchema } from '@vee-validate/zod'
92-
// import { useForm } from 'vee-validate'
93-
// import * as z from 'zod'
94-
95-
const editableSettings = ref<Setting[]>([])
96-
97-
watch(() => selectedGroup.value, (newGroup) => { // Removed explicit type for newGroup
98-
if (newGroup && newGroup.settings) {
99-
editableSettings.value = JSON.parse(JSON.stringify(newGroup.settings))
100-
} else {
101-
editableSettings.value = []
102-
}
103-
}, { immediate: true, deep: true })
88+
import { Switch } from '@/components/ui/switch'
89+
import {
90+
FormControl,
91+
FormDescription,
92+
FormField,
93+
FormItem,
94+
FormLabel,
95+
FormMessage,
96+
} from '@/components/ui/form'
97+
// VeeValidate and Zod for form validation
98+
import { toTypedSchema } from '@vee-validate/zod'
99+
import { useForm } from 'vee-validate'
100+
import * as z from 'zod'
104101
105-
function getSettingInputType(setting: Setting): string {
106-
if (setting.is_encrypted) {
107-
return 'password'
108-
}
109-
// Add more logic here if type information is available (e.g., for numbers, booleans using Switch)
110-
// For now, default to text.
111-
// Example: if (setting.value_type === 'boolean') return 'checkbox'; (would need Switch component)
112-
// if (typeof setting.value === 'number') return 'number';
113-
return 'text'
114-
}
102+
// Create dynamic Zod schema based on settings
103+
function createSettingsSchema(settings: Setting[]) {
104+
const schemaObject: Record<string, z.ZodTypeAny> = {}
115105
116-
async function handleSaveChanges() {
117-
if (!selectedGroup.value) return // selectedGroup itself could be null
106+
settings.forEach(setting => {
107+
switch (setting.type) {
108+
case 'string':
109+
schemaObject[setting.key] = z.string()
110+
break
111+
case 'number':
112+
schemaObject[setting.key] = z.number()
113+
break
114+
case 'boolean':
115+
schemaObject[setting.key] = z.boolean()
116+
break
117+
}
118+
})
118119
119-
const originalSettings = selectedGroup.value.settings
120-
if (!originalSettings) return // No original settings to compare against
120+
return z.object(schemaObject)
121+
}
121122
122-
const changedSettings = editableSettings.value.filter((editedSetting, index) => {
123-
const originalSetting = originalSettings[index]
124-
return originalSetting && editedSetting.value !== originalSetting.value
123+
// Create initial form values from settings
124+
function createInitialValues(settings: Setting[]) {
125+
const values: Record<string, any> = {}
126+
settings.forEach(setting => {
127+
switch (setting.type) {
128+
case 'number':
129+
values[setting.key] = Number(setting.value) || 0
130+
break
131+
case 'boolean':
132+
values[setting.key] = setting.value === 'true' || setting.value === true
133+
break
134+
case 'string':
135+
default:
136+
values[setting.key] = setting.value || ''
137+
break
138+
}
125139
})
140+
return values
141+
}
142+
143+
// Form setup
144+
const formSchema = computed(() => {
145+
if (!selectedGroup.value?.settings) return z.object({})
146+
return createSettingsSchema(selectedGroup.value.settings)
147+
})
148+
149+
const initialValues = computed(() => {
150+
if (!selectedGroup.value?.settings) return {}
151+
return createInitialValues(selectedGroup.value.settings)
152+
})
153+
154+
const form = useForm({
155+
validationSchema: computed(() => toTypedSchema(formSchema.value)),
156+
initialValues: initialValues
157+
})
126158
127-
if (changedSettings.length === 0) {
128-
// console.log('No changes to save.') // Removed console log
129-
successAlertMessage.value = t('globalSettings.alerts.noChanges'); // New i18n key
130-
showSuccessAlert.value = true;
131-
// setTimeout for this specific alert could be added if desired, or rely on manual close
132-
return
159+
// Watch for group changes and reset form
160+
watch(() => selectedGroup.value, (newGroup) => {
161+
if (newGroup?.settings) {
162+
const newInitialValues = createInitialValues(newGroup.settings)
163+
form.resetForm({ values: newInitialValues })
133164
}
165+
}, { immediate: true, deep: true })
166+
167+
const onSubmit = form.handleSubmit(async (values) => {
168+
if (!selectedGroup.value) return
134169
135-
console.log('Saving changed settings:', changedSettings)
170+
console.log('Form values:', values)
136171
137-
// Prepare settings for bulk update, ensuring all necessary fields are present
138-
const settingsToUpdate = changedSettings.map(setting => {
139-
// Find the original setting to get all its properties, as changedSettings might only have key/value
140-
const originalFullSetting = selectedGroup.value?.settings?.find(s => s.key === setting.key)
172+
// Convert form values to API format
173+
const settingsToUpdate = Object.entries(values).map(([key, value]) => {
174+
const setting = selectedGroup.value?.settings?.find(s => s.key === key)
141175
return {
142-
key: setting.key,
143-
value: setting.value,
144-
group_id: selectedGroup.value?.id, // Add group_id
145-
description: originalFullSetting?.description, // Preserve description
146-
is_encrypted: originalFullSetting?.is_encrypted, // Preserve encryption status
176+
key,
177+
value: String(value), // API expects string values
178+
type: setting?.type,
179+
group_id: selectedGroup.value?.id,
180+
description: setting?.description,
181+
encrypted: setting?.is_encrypted
147182
}
148183
})
149184
150185
try {
151186
if (!apiUrl) {
152187
throw new Error('VITE_DEPLOYSTACK_BACKEND_URL is not configured for saving settings.')
153188
}
189+
154190
const response = await fetch(`${apiUrl}/api/settings/bulk`, {
155191
method: 'POST',
156192
headers: {
157193
'Content-Type': 'application/json',
158-
// Add Authorization header if needed: 'Authorization': `Bearer ${yourAuthToken}`
159194
},
160-
credentials: 'include', // Added credentials include
195+
credentials: 'include',
161196
body: JSON.stringify({ settings: settingsToUpdate }),
162197
})
163198
@@ -168,44 +203,35 @@ async function handleSaveChanges() {
168203
169204
const result = await response.json()
170205
if (!result.success) {
171-
// Handle cases where API returns success: false but HTTP status is 200
172206
throw new Error(result.message || 'Failed to save settings due to an API error.')
173207
}
174208
175-
console.log('API Save Result:', result)
176-
// Successfully saved, now update the local state to reflect changes
177-
// This ensures the UI is in sync without needing an immediate refetch.
209+
console.log('Settings saved successfully via API.')
210+
successAlertMessage.value = t('globalSettings.alerts.saveSuccess')
211+
showSuccessAlert.value = true
212+
213+
// Update local state
178214
const groupIndex = settingGroups.value.findIndex(g => g.id === selectedGroup.value?.id)
179215
if (groupIndex !== -1) {
180-
// Create a new copy of the group with updated settings
216+
const updatedSettings = selectedGroup.value.settings?.map(setting => ({
217+
...setting,
218+
value: String((values as Record<string, any>)[setting.key])
219+
}))
220+
181221
const updatedGroup = {
182222
...settingGroups.value[groupIndex],
183-
settings: JSON.parse(JSON.stringify(editableSettings.value)) // Use the edited settings
223+
settings: updatedSettings
184224
}
185-
// Replace the old group with the updated one
225+
186226
const newSettingGroups = [...settingGroups.value]
187227
newSettingGroups[groupIndex] = updatedGroup
188228
settingGroups.value = newSettingGroups
189229
}
190-
console.log('Settings saved successfully via API.')
191-
successAlertMessage.value = t('globalSettings.alerts.saveSuccess'); // Assuming you have i18n keys
192-
showSuccessAlert.value = true;
193-
// Removed setTimeout to make the alert persistent until manually closed or next save
194-
195-
// Optionally, use a toast notification for success
196-
// e.g., toast({ title: 'Settings Saved', description: 'Your changes have been successfully saved.' })
197-
198-
// Optionally, refetch all groups to ensure data consistency,
199-
// or merge changes carefully if the API returns the updated settings.
200-
// For now, the local update above handles immediate UI feedback.
201-
// await fetchSettingGroupsApi().then(data => settingGroups.value = data); // Example refetch
202230
203231
} catch (saveError) {
204232
console.error('Failed to save settings via API:', saveError)
205-
// Optionally, use a toast notification for error
206-
// e.g., toast({ title: 'Error Saving Settings', description: (saveError as Error).message, variant: 'destructive' })
207233
}
208-
}
234+
})
209235
210236
</script>
211237

@@ -246,18 +272,43 @@ async function handleSaveChanges() {
246272
{{ selectedGroup.description }}
247273
</p>
248274
</div>
249-
<form v-if="editableSettings.length > 0" class="space-y-6" @submit.prevent="handleSaveChanges">
250-
<div v-for="(setting, index) in editableSettings" :key="setting.key" class="space-y-2">
251-
<Label :for="`setting-${setting.key}`">{{ setting.description || setting.key }}</Label>
252-
<Input
253-
:id="`setting-${setting.key}`"
254-
:type="getSettingInputType(setting)"
255-
v-model="editableSettings[index].value"
256-
class="w-full"
257-
/>
258-
<p v-if="setting.is_encrypted" class="text-xs text-muted-foreground">This value is encrypted.</p>
259-
<!-- Add FormDescription and FormMessage here if using full VeeValidate structure -->
260-
</div>
275+
<form v-if="selectedGroup.settings && selectedGroup.settings.length > 0" class="space-y-6" @submit="onSubmit">
276+
<FormField
277+
v-for="setting in selectedGroup.settings"
278+
:key="setting.key"
279+
v-slot="{ componentField }"
280+
:name="setting.key"
281+
>
282+
<FormItem>
283+
<FormLabel>{{ setting.description || setting.key }}</FormLabel>
284+
<FormControl>
285+
<!-- String Input (text or password) -->
286+
<Input
287+
v-if="setting.type === 'string'"
288+
:type="setting.is_encrypted ? 'password' : 'text'"
289+
v-bind="componentField"
290+
/>
291+
292+
<!-- Number Input -->
293+
<Input
294+
v-else-if="setting.type === 'number'"
295+
type="number"
296+
v-bind="componentField"
297+
/>
298+
299+
<!-- Boolean Toggle Switch -->
300+
<Switch
301+
v-else-if="setting.type === 'boolean'"
302+
v-bind="componentField"
303+
/>
304+
</FormControl>
305+
<FormDescription v-if="setting.is_encrypted">
306+
This value is encrypted.
307+
</FormDescription>
308+
<FormMessage />
309+
</FormItem>
310+
</FormField>
311+
261312
<Button type="submit">
262313
Save Changes
263314
</Button>

0 commit comments

Comments
 (0)