Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
4 changes: 4 additions & 0 deletions __tests__/unit/client/components/settings-wrapper.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ const mockDeleteSettingsAction = jest.fn()
const mockUpdateServersAction = jest.fn()
const mockTestConnectionAction = jest.fn().mockResolvedValue('success')
const mockTestInfluxConnectionAction = jest.fn()
const mockUpdateNotificationProvidersAction = jest.fn()
const mockTestNotificationProvidersAction = jest.fn()

const renderComponent = () =>
render(
Expand All @@ -33,6 +35,8 @@ const renderComponent = () =>
updateServersAction={mockUpdateServersAction}
testConnectionAction={mockTestConnectionAction}
testInfluxConnectionAction={mockTestInfluxConnectionAction}
updateNotificationProvidersAction={mockUpdateNotificationProvidersAction}
testNotificationProviderAction={mockTestNotificationProvidersAction}
/>
</LanguageContext.Provider>
)
Expand Down
6 changes: 3 additions & 3 deletions __tests__/unit/server/scheduler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,8 +191,8 @@ describe('Scheduler', () => {
onChangeFn()

// Check if job was removed and added again
expect(mockToadScheduler.existsById).toHaveBeenCalledWith('id_1')
expect(mockToadScheduler.addSimpleIntervalJob).toHaveBeenCalledTimes(2) // Once on init, once on change
expect(mockToadScheduler.existsById).toHaveBeenCalledWith('influxdb_job')
expect(mockToadScheduler.addSimpleIntervalJob).toHaveBeenCalledTimes(4) // Once on init, once on change
}
})

Expand All @@ -217,7 +217,7 @@ describe('Scheduler', () => {
onChangeFn()

// Job should not be updated because settings are incomplete
expect(mockToadScheduler.addSimpleIntervalJob).not.toHaveBeenCalled()
expect(mockToadScheduler.addSimpleIntervalJob).toHaveBeenCalledTimes(1)
}
})

Expand Down
27 changes: 26 additions & 1 deletion src/app/actions.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,20 @@
'use server'

import InfluxWriter from '@/server/influxdb'
import {
DEVICE,
NotificationTrigger,
NotificationProviders,
NotifierSettings,
server,
DeviceData,
VarDescription,
DevicesData,
} from '@/common/types'
import { Notifier } from '@/server/notifications/notifier'
import { NotifierFactory } from '@/server/notifications/notifier-factory'
import { Nut } from '@/server/nut'
import { YamlSettings, SettingsType } from '@/server/settings'
import { DEVICE, server, DeviceData, DevicesData, VarDescription } from '@/common/types'
import chokidar from 'chokidar'
import { AuthError } from 'next-auth'
import { signIn, signOut } from '@/auth'
Expand Down Expand Up @@ -286,6 +297,20 @@ export async function updateServers(servers: Array<server>) {
settings.set('NUT_SERVERS', servers)
}

export async function testNotificationProvider(
name: (typeof NotificationProviders)[number],
triggers: NotificationTrigger[],
config: { [x: string]: string } | undefined
) {
const notificationProvider: Notifier = NotifierFactory({ name, triggers, config })
return await notificationProvider.sendTestNotification()
}

export async function updateNotificationProviders(notificationProviders: Array<NotifierSettings>) {
const settings = new YamlSettings(settingsFile)
settings.set('NOTIFICATION_PROVIDERS', notificationProviders)
}

export async function deleteSettings(key: keyof SettingsType) {
const settings = getCachedSettings()
settings.delete(key)
Expand Down
4 changes: 4 additions & 0 deletions src/app/settings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import {
deleteSettings,
testConnection,
testInfluxConnection,
updateNotificationProviders,
testNotificationProvider,
} from '@/app/actions'

export default function Settings() {
Expand All @@ -24,6 +26,8 @@ export default function Settings() {
updateServersAction={updateServers}
testConnectionAction={testConnection}
testInfluxConnectionAction={testInfluxConnection}
updateNotificationProvidersAction={updateNotificationProviders}
testNotificationProviderAction={testNotificationProvider}
/>
)
}
280 changes: 280 additions & 0 deletions src/client/components/add-notification-provider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,280 @@
import React, { useContext, useState, useTransition } from 'react'
import { useTranslation } from 'react-i18next'
import { Toaster, toast } from 'sonner'
import { HiOutlineXMark, HiOutlinePlus } from 'react-icons/hi2'
import { useTheme } from 'next-themes'
import { LanguageContext } from '@/client/context/language'
import { Button } from '@/client/components/ui/button'
import { Input } from '@/client/components/ui/input'
import { Label } from '@/client/components/ui/label'
import { Card } from '@/client/components/ui/card'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/client/components/ui/select'
import { NotificationProviders, NotificationTrigger, NotificationTriggerOperations } from '@/common/types'

type AddNotificationProviderProps = {
initialName: (typeof NotificationProviders)[number]
initialTriggers: Array<NotificationTrigger>
initialConfig?: { [x: string]: string }
handleChange: (
name: (typeof NotificationProviders)[number],
triggers: Array<NotificationTrigger>,
config?: { [x: string]: string }
) => void
handleRemove: () => void
testNotificationProviderAction: (
name: (typeof NotificationProviders)[number],
triggers: NotificationTrigger[],
config?: { [x: string]: string }
) => Promise<string>
}

export default function AddNotificationProvider({
initialName,
initialTriggers,
initialConfig,
handleChange,
handleRemove,
testNotificationProviderAction,
}: Readonly<AddNotificationProviderProps>) {
const lng = useContext<string>(LanguageContext)
const { t } = useTranslation(lng)
const { theme } = useTheme()
const [name, setName] = useState<(typeof NotificationProviders)[number]>(initialName)
const [triggers, setTriggers] = useState<Array<NotificationTrigger>>(initialTriggers)
const [config, setConfig] = useState<{ [x: string]: string } | undefined>(initialConfig)
const [connecting, startTransition] = useTransition()

const handleTestNotification = async () => {
if (name) {
startTransition(async () => {
const promise = testNotificationProviderAction(name, triggers, config)
toast.promise(promise, {
loading: t('notification.testing'),
success: t('notification.success'),
error: t('notification.error'),
})
try {
await promise
} catch {
// Do nothing
}
})
}
}

return (
<Card className='border-border bg-card mt-1 mb-4 w-full border pb-6 pl-6 shadow-none'>
<Toaster position='top-center' theme={theme as 'light' | 'dark' | 'system'} richColors />
<div className='h-12'>
<Button
variant='ghost'
type='button'
className='text-md float-right px-3 shadow-none'
title={t('settings.remove')}
onClick={handleRemove}
>
<HiOutlineXMark className='h-6 w-6 stroke-1' />
</Button>
</div>
<div className='pr-6'>
<form className='w-full'>
<div className='mb-4'>
<Select
onValueChange={(e) => {
const newName = e as (typeof NotificationProviders)[number]
setName(newName)
handleChange(newName, triggers, config)
}}
value={name}
>
<SelectTrigger className='border-border-card w-full px-3 py-2'>
<SelectValue placeholder={t('notification.name')} />
</SelectTrigger>
<SelectContent>
{NotificationProviders.map((np) => (
<SelectItem key={np} value={np}>
{np}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<h3>{t('notification.trigger.heading')}</h3>
{triggers.map((trigger, index) => (
<div className='my-6 border-l-4 border-gray-600 pl-6' key={index}>
<div className='h-12'>
<Button
variant='ghost'
type='button'
className='text-md float-right px-3 shadow-none'
title={t('settings.remove')}
onClick={() => {
const newTriggers = [...triggers.filter((_trigger, _index) => _index !== index)]
setTriggers(newTriggers)
handleChange(name, newTriggers, config)
}}
>
<HiOutlineXMark className='h-6 w-6 stroke-1' />
</Button>
</div>
<div className='mt-4'>
<Label htmlFor='notificationTriggerVariable'>{t('notification.trigger.variable')}</Label>
<Input
required
id='notificationTriggerVariable'
type='text'
value={trigger.variable}
onChange={(e) => {
trigger.variable = e.target.value
setTriggers([...triggers])
handleChange(name, triggers, config)
}}
className='w-full px-3 py-2'
data-testid={`${name}-trigger-variable`}
/>
</div>
<div className='mt-4'>
<Select
onValueChange={(e) => {
console.dir(e)
const newTriggers = [...triggers]
newTriggers[index] = {
...newTriggers[index],
operation: e as (typeof NotificationTriggerOperations)[number],
}
setTriggers(newTriggers)
handleChange(name, newTriggers, config)
}}
value={trigger.operation}
>
<SelectTrigger className='border-border-card w-full px-3 py-2'>
<SelectValue placeholder={t('notification.trigger.operation')} />
</SelectTrigger>
<SelectContent>
{NotificationTriggerOperations.map((op) => (
<SelectItem key={op} value={op}>
{op}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className='mt-4'>
<Label htmlFor='notificationTriggerTargetValue'>{t('notification.trigger.targetValue')}</Label>
<Input
type='number'
id='notificationTriggerTargetValue'
value={trigger.targetValue ?? 0}
onChange={(e) => {
const newTriggers = [...triggers]
newTriggers[index] = {
...newTriggers[index],
targetValue: e.target.valueAsNumber,
}
setTriggers(newTriggers)
handleChange(name, newTriggers, config)
}}
className='w-full px-3 py-2'
data-testid={`${name}-trigger-targetValue`}
/>
</div>
</div>
))}
<div className='text-center'>
<Button
variant='secondary'
title={t('notification.trigger.buttonAdd')}
className='shadow-none'
type='button'
onClick={() => setTriggers([...triggers, { variable: '', operation: 'changes' }])}
>
<HiOutlinePlus className='!h-6 !w-6' />
</Button>
</div>
<h3>{t('notification.config.heading')}</h3>
{config &&
Object.keys(config).map((k) => (
<div className='my-6 border-l-4 border-gray-600 pl-6' key={k}>
<div className='h-12'>
<Button
variant='ghost'
className='text-md float-right px-3 shadow-none'
title={t('settings.remove')}
type='button'
onClick={() => {
const newConfig = { ...config }
delete newConfig[k]
setConfig(newConfig)
handleChange(name, triggers, newConfig)
}}
>
<HiOutlineXMark className='h-6 w-6 stroke-1' />
</Button>
</div>
<div className='mt-4'>
<Label htmlFor='notificationConfigPropertyName'>{t('notification.config.propertyName')}</Label>
<Input
required
type='text'
id='notificationConfigPropertyName'
value={k}
onChange={(e) => {
const newConfig = {
...config,
}
console.log(k)
const configValue = newConfig[k]
delete newConfig[k]
newConfig[e.target.value] = configValue
setConfig(newConfig)
handleChange(name, triggers, newConfig)
}}
className='w-full px-3 py-2'
data-testid={`${name}-config-key`}
/>
</div>
<div className='mt-4'>
<Label htmlFor='notificationConfigPropertyValue'>{t('notification.config.propertyValue')}</Label>
<Input
type='text'
id='notificationConfigPropertyValue'
value={config[k]}
onChange={(e) => {
const newConfig = { ...config, [k]: e.target.value }
setConfig(newConfig)
handleChange(name, triggers, newConfig)
}}
className='w-full px-3 py-2'
data-testid={`${name}-config-value`}
/>
</div>
</div>
))}
<div className='text-center'>
<Button
variant='secondary'
title={t('notification.config.buttonAdd')}
className='shadow-none'
type='button'
onClick={() => setConfig({ ...config, property: 'value' })}
>
<HiOutlinePlus className='!h-6 !w-6' />
</Button>
</div>
<div className='flex flex-row justify-between'>
<div />
<Button
variant='destructive'
disabled={connecting}
onClick={async () => handleTestNotification()}
className='shadow-none'
type='button'
>
{t('connect.test')}
</Button>
</div>
</form>
</div>
</Card>
)
}
Loading
Loading