diff --git a/frontend/src/components/forms/OutputProfile.jsx b/frontend/src/components/forms/OutputProfile.jsx index 541c911b..610c0ef4 100644 --- a/frontend/src/components/forms/OutputProfile.jsx +++ b/frontend/src/components/forms/OutputProfile.jsx @@ -1,8 +1,5 @@ import React, { useEffect, useMemo, useState } from 'react'; import { useForm } from 'react-hook-form'; -import { yupResolver } from '@hookform/resolvers/yup'; -import * as Yup from 'yup'; -import API from '../../api'; import { Modal, TextInput, @@ -13,27 +10,14 @@ import { Stack, Checkbox, } from '@mantine/core'; - -const BUILT_IN_COMMANDS = [ - { value: 'ffmpeg', label: 'FFmpeg' }, - { value: '__custom__', label: 'Custom…' }, -]; - -const COMMAND_EXAMPLES = { - ffmpeg: - '-i pipe:0 -c:v libx264 -b:v 2000k -vf scale=-2:720 -c:a copy -f mpegts pipe:1', -}; - -const toCommandSelection = (command) => - BUILT_IN_COMMANDS.find((o) => o.value === command && o.value !== '__custom__') - ? command - : '__custom__'; - -const schema = Yup.object({ - name: Yup.string().required('Name is required'), - command: Yup.string().required('Command is required'), - parameters: Yup.string(), -}); +import { + addOutputProfile, + BUILT_IN_COMMANDS, + COMMAND_EXAMPLES, + getResolver, + toCommandSelection, + updateOutputProfile, +} from '../../utils/forms/OutputProfileUtils'; const OutputProfile = ({ profile = null, isOpen, onClose }) => { const [commandSelection, setCommandSelection] = useState('ffmpeg'); @@ -57,7 +41,7 @@ const OutputProfile = ({ profile = null, isOpen, onClose }) => { watch, } = useForm({ defaultValues, - resolver: yupResolver(schema), + resolver: getResolver(), }); useEffect(() => { @@ -67,9 +51,9 @@ const OutputProfile = ({ profile = null, isOpen, onClose }) => { const onSubmit = async (values) => { if (profile?.id) { - await API.updateOutputProfile({ id: profile.id, ...values }); + await updateOutputProfile({ id: profile.id, ...values }); } else { - await API.addOutputProfile(values); + await addOutputProfile(values); } reset(); onClose(); diff --git a/frontend/src/components/forms/ServerGroup.jsx b/frontend/src/components/forms/ServerGroup.jsx index 80c8c1a4..0d5ecd05 100644 --- a/frontend/src/components/forms/ServerGroup.jsx +++ b/frontend/src/components/forms/ServerGroup.jsx @@ -1,20 +1,13 @@ import React, { useEffect, useMemo } from 'react'; import { useForm } from 'react-hook-form'; -import { yupResolver } from '@hookform/resolvers/yup'; -import * as Yup from 'yup'; -import API from '../../api'; import { Button, Flex, Modal, TextInput } from '@mantine/core'; +import { + getResolver, + updateServerGroup, + addServerGroup, +} from '../../utils/forms/ServerGroupUtils'; -const schema = Yup.object({ - name: Yup.string().required('Name is required'), -}); - -const ServerGroupForm = ({ - serverGroup = null, - isOpen, - onClose, - onSaved, -}) => { +const ServerGroupForm = ({ serverGroup = null, isOpen, onClose, onSaved }) => { const defaultValues = useMemo( () => ({ name: serverGroup?.name || '', @@ -29,16 +22,13 @@ const ServerGroupForm = ({ reset, } = useForm({ defaultValues, - resolver: yupResolver(schema), + resolver: getResolver(), }); const onSubmit = async (values) => { - let response; - if (serverGroup?.id) { - response = await API.updateServerGroup({ id: serverGroup.id, ...values }); - } else { - response = await API.addServerGroup(values); - } + const response = serverGroup?.id + ? await updateServerGroup({ id: serverGroup.id, ...values }) + : await addServerGroup(values); if (response) { onSaved?.(response); diff --git a/frontend/src/components/forms/__tests__/OutputProfile.test.jsx b/frontend/src/components/forms/__tests__/OutputProfile.test.jsx new file mode 100644 index 00000000..f06e89e1 --- /dev/null +++ b/frontend/src/components/forms/__tests__/OutputProfile.test.jsx @@ -0,0 +1,534 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// ── Module-level form state ──────────────────────────────────────────────────── +const __form = { values: {}, resetSpy: null }; + +// ── Utility mocks ────────────────────────────────────────────────────────────── +vi.mock('../../../utils/forms/OutputProfileUtils', () => ({ + BUILT_IN_COMMANDS: [ + { value: 'ffmpeg', label: 'FFmpeg' }, + { value: '__custom__', label: 'Custom…' }, + ], + COMMAND_EXAMPLES: { + ffmpeg: '-i pipe:0 -c:v libx264 -f mpegts pipe:1', + }, + addOutputProfile: vi.fn(), + updateOutputProfile: vi.fn(), + getResolver: vi.fn(() => undefined), + toCommandSelection: vi.fn((cmd) => + cmd === 'ffmpeg' ? 'ffmpeg' : '__custom__' + ), +})); + +// ── react-hook-form ──────────────────────────────────────────────────────────── +vi.mock('react-hook-form', async () => { + const React = await import('react'); + return { + useForm: vi.fn(({ defaultValues } = {}) => { + const [formValues, setFormValues] = React.useState(() => { + const vals = defaultValues || {}; + Object.assign(__form.values, vals); + return vals; + }); + + const updateField = (name, value) => { + __form.values[name] = value; + setFormValues((prev) => ({ ...prev, [name]: value })); + }; + + const register = (name) => ({ + name, + value: __form.values[name] ?? '', + onChange: (e) => updateField(name, e.target.value), + onBlur: () => {}, + }); + + const setValue = (name, value) => updateField(name, value); + const watch = (name) => formValues[name]; + + const handleSubmit = (onSubmit) => (e) => { + e?.preventDefault?.(); + return onSubmit({ ...__form.values }); + }; + + const resetImpl = React.useCallback((newValues) => { + const vals = newValues || defaultValues || {}; + Object.assign(__form.values, vals); + setFormValues({ ...vals }); + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + const resetRef = React.useRef(null); + if (!resetRef.current) { + resetRef.current = vi.fn((...args) => resetImpl(...args)); + __form.resetSpy = resetRef.current; + } + + return { + register, + handleSubmit, + formState: { errors: {}, isSubmitting: false }, + reset: resetRef.current, + setValue, + watch, + }; + }), + }; +}); + +// ── @mantine/core ────────────────────────────────────────────────────────────── +vi.mock('@mantine/core', () => ({ + Button: ({ children, type, disabled }) => ( + + ), + Checkbox: ({ label, checked, onChange }) => ( +
+ + + onChange({ currentTarget: { checked: e.target.checked } }) + } + /> +
+ ), + Flex: ({ children }) =>
{children}
, + Modal: ({ children, opened, onClose, title }) => + opened ? ( +
+
{title}
+ + {children} +
+ ) : null, + Select: ({ label, value, onChange, data, disabled }) => ( +
+ + +
+ ), + Stack: ({ children }) =>
{children}
, + Textarea: ({ + label, + name, + value, + onChange, + placeholder, + description, + disabled, + ...rest + }) => ( +
+ + {description && ( +
+ {description} +
+ )} +