Integrations data (#770)

* feat: OSC settings

* feat: HTTP settings
This commit is contained in:
Carlos Valente
2024-02-11 21:25:23 +01:00
committed by GitHub
parent 5355e45b80
commit 53963a9ad7
52 changed files with 742 additions and 1620 deletions
@@ -28,7 +28,7 @@
padding: 1rem;
background-color: $white-1;
border: 1px solid $gray-1100;
border-radius: 0.25rem;
border-radius: 3px;
}
.error {
@@ -87,3 +87,8 @@
font-size: calc(1rem - 2px);
color: $gray-400;
}
.fieldError {
font-size: calc(1rem - 2px);
color: $red-500;
}
@@ -1,4 +1,4 @@
import { ReactNode } from 'react';
import { HTMLAttributes, ReactNode } from 'react';
import style from './Panel.module.scss';
@@ -10,8 +10,19 @@ export function SubHeader({ children }: { children: ReactNode }) {
return <h3 className={style.subheader}>{children}</h3>;
}
export function Section({ children }: { children: ReactNode }) {
return <div className={style.section}>{children}</div>;
type AllowedTags = 'div' | 'form';
type SectionProps<C extends AllowedTags> = {
as?: C;
children: ReactNode;
} & JSX.IntrinsicElements[C];
export function Section<C extends AllowedTags = 'div'>({ as, children, ...props }: SectionProps<C>) {
const Element = as ?? 'div';
return (
<Element className={style.section} {...(props as HTMLAttributes<HTMLElement>)}>
{children}
</Element>
);
}
export function Paragraph({ children }: { children: ReactNode }) {
@@ -34,11 +45,20 @@ export function ListItem({ children }: { children: ReactNode }) {
return <li className={style.listItem}>{children}</li>;
}
export function Field({ title, description }: { title: string; description: string }) {
export function Field({ title, description, error }: { title: string; description: string; error?: string }) {
return (
<div className={style.fieldTitle}>
{title}
{description && <div className={style.fieldDescription}>{description}</div>}
{error && <Error>{error}</Error>}
{!error && description && <Description>{description}</Description>}
</div>
);
}
export function Description({ children }: { children: ReactNode }) {
return <div className={style.fieldDescription}>{children}</div>;
}
export function Error({ children }: { children: ReactNode }) {
return <div className={style.fieldError}>{children}</div>;
}
@@ -1,29 +1,94 @@
import { useFieldArray, useForm } from 'react-hook-form';
import { Button, IconButton, Input, Select, Switch } from '@chakra-ui/react';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
import { HttpSettings } from 'ontime-types';
import { generateId } from 'ontime-utils';
import { maybeAxiosError } from '../../../../common/api/apiUtils';
import { useHttpSettings, usePostHttpSettings } from '../../../../common/hooks-query/useHttpSettings';
import { isKeyEscape } from '../../../../common/utils/keyEvent';
import { startsWithHttp } from '../../../../common/utils/regex';
import * as Panel from '../PanelUtils';
import { cycles } from './IntegrationsPanel';
import { cycles } from './integrationUtils';
import style from './IntegrationsPanel.module.css';
const demoIntegrations = [
{ id: 1, enabled: true, cycle: 'onLoad', message: '/ontime/scene/1' },
{ id: 2, enabled: true, cycle: 'onLoad', message: '/ontime/scene/2' },
{ id: 3, enabled: true, cycle: 'onLoad', message: '/ontime/scene/3' },
{ id: 4, enabled: true, cycle: 'onLoad', message: '/ontime/scene/4' },
];
export default function HttpIntegrations() {
const { data } = useHttpSettings();
const { mutateAsync } = usePostHttpSettings();
const {
control,
handleSubmit,
reset,
register,
setError,
formState: { errors, isSubmitting, isDirty, isValid },
} = useForm<HttpSettings>({
mode: 'onBlur',
defaultValues: data,
values: data,
resetOptions: {
keepDirtyValues: true,
},
});
const { fields, prepend, remove } = useFieldArray({
name: 'subscriptions',
control,
});
const onSubmit = async (values: HttpSettings) => {
try {
await mutateAsync(values);
} catch (error) {
setError('root', { message: maybeAxiosError(error) });
}
};
const preventEscape = (event: React.KeyboardEvent) => {
if (isKeyEscape(event)) {
event.preventDefault();
event.stopPropagation();
}
};
const handleAddNewSubscription = () => {
prepend({
id: generateId(),
cycle: 'onLoad',
message: '',
enabled: false,
});
};
const handleDeleteSubscription = (index: number) => {
remove(index);
};
const canSubmit = !isSubmitting && isDirty && isValid;
return (
<Panel.Section>
<Panel.Section as='form' onSubmit={handleSubmit(onSubmit)} onKeyDown={preventEscape}>
<Panel.Section>
<Panel.SubHeader>HTTP integrations</Panel.SubHeader>
<Panel.SubHeader>
HTTP settings
<div className={style.flex}>
<Button variant='ontime-ghosted' size='sm' onClick={() => reset()} isDisabled={!canSubmit}>
Reset
</Button>
<Button variant='ontime-filled' size='sm' type='submit' isDisabled={!canSubmit} isLoading={isSubmitting}>
Save changes
</Button>
</div>
</Panel.SubHeader>
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}
<Panel.ListGroup>
<Panel.ListItem>
<Panel.Field title='HTTP Output' description='Provide feedback from Ontime through HTTP' />
<Switch variant='ontime' size='lg' />
<Switch variant='ontime' size='lg' {...register('enabledOut')} />
</Panel.ListItem>
</Panel.ListGroup>
</Panel.Section>
@@ -32,51 +97,75 @@ export default function HttpIntegrations() {
<Panel.Card>
<Panel.SubHeader>
HTTP Integration
<Button variant='ontime-subtle' size='sm' rightIcon={<IoAdd />} onClick={() => undefined}>
<Button variant='ontime-subtle' size='sm' rightIcon={<IoAdd />} onClick={handleAddNewSubscription}>
New
</Button>
</Panel.SubHeader>
<Panel.Paragraph>Integration Settings for OSC protocol</Panel.Paragraph>
<Panel.Table>
<thead>
<tr>
<th>Enabled</th>
<th>Cycle</th>
<th className={style.fullWidth}>Message</th>
<th />
</tr>
</thead>
<tbody>
{demoIntegrations.map((integration) => (
<tr key={integration.id}>
<td>
<Switch variant='ontime' />
</td>
<td className={style.autoWidth}>
<Select size='sm' variant='ontime' className={style.fitContents}>
{cycles.map((cycle) => (
<option key={cycle.id} value={cycle.value}>
{cycle.label}
</option>
))}
</Select>
</td>
<td className={style.fullWidth}>
<Input size='sm' variant='ontime-filled' value={integration.message} />
</td>
<td>
<IconButton
size='sm'
variant='ontime-ghosted'
color='#FA5656' // $red-500
icon={<IoTrash />}
aria-label='Delete entry'
/>
</td>
{fields.length > 0 && (
<Panel.Table>
<thead>
<tr>
<th>Enabled</th>
<th>Cycle</th>
<th className={style.fullWidth}>Message</th>
<th />
</tr>
))}
</tbody>
</Panel.Table>
</thead>
<tbody>
{fields.map((integration, index) => {
// @ts-expect-error -- not sure why it is not finding the type, it is ok
const maybeError = errors.subscriptions?.[index]?.message?.message;
return (
<tr key={integration.id}>
<td>
<Switch variant='ontime' {...register(`subscriptions.${index}.enabled`)} />
</td>
<td className={style.autoWidth}>
<Select
size='sm'
variant='ontime'
className={style.fitContents}
{...register(`subscriptions.${index}.cycle`)}
>
{cycles.map((cycle) => (
<option key={cycle.id} value={cycle.value}>
{cycle.label}
</option>
))}
</Select>
</td>
<td className={style.fullWidth}>
<Input
size='sm'
variant='ontime-filled'
autoComplete='off'
placeholder='http://third-party/vt1/{{timer.current}}'
{...register(`subscriptions.${index}.message`, {
required: { value: true, message: 'Required field' },
pattern: {
value: startsWithHttp,
message: 'HTTP messages should start with http://',
},
})}
/>
{maybeError && <Panel.Error>{maybeError}</Panel.Error>}
</td>
<td>
<IconButton
size='sm'
variant='ontime-ghosted'
color='#FA5656' // $red-500
icon={<IoTrash />}
aria-label='Delete entry'
onClick={() => handleDeleteSubscription(index)}
/>
</td>
</tr>
);
})}
</tbody>
</Panel.Table>
)}
</Panel.Card>
</Panel.Section>
</Panel.Section>
@@ -5,3 +5,8 @@
.fitContents {
width: max-content !important; /* override chakra */
}
.flex {
display: flex;
gap: 1rem;
}
@@ -1,21 +1,29 @@
import { Alert, AlertDescription, AlertIcon } from '@chakra-ui/react';
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
import * as Panel from '../PanelUtils';
import HttpIntegrations from './HttpIntegrations';
import OscIntegrations from './OscIntegrations';
export const cycles = [
{ id: 1, label: 'On Load', value: 'onLoad' },
{ id: 2, label: 'On Start', value: 'onStart' },
{ id: 3, label: 'On Pause', value: 'onPause' },
{ id: 4, label: 'On Stop', value: 'onStop' },
{ id: 5, label: 'Every second', value: 'onUpdate' },
{ id: 6, label: 'On Finish', value: 'onFinish' },
];
const integrationDocsUrl = 'https://ontime.gitbook.io/v2/control-and-feedback/integrations';
export default function IntegrationsPanel() {
return (
<>
<Panel.Header>Integration settings</Panel.Header>
<Panel.Section>
<Alert status='info' variant='ontime-on-dark-info'>
<AlertIcon />
<AlertDescription>
Integrations allow Ontime to receive commands or send its data to other systems in your workflow. <br />{' '}
<br />
Currently supported protocols are OSC (Open Sound Control), HTTP and Websockets. <br />
WebSockets are used for Ontime and cannot be configured independently. <br />
<ExternalLink href={integrationDocsUrl}>See the docs</ExternalLink>
</AlertDescription>
</Alert>
</Panel.Section>
<OscIntegrations />
<HttpIntegrations />
</>
@@ -1,114 +1,258 @@
import { useFieldArray, useForm } from 'react-hook-form';
import { Button, IconButton, Input, Select, Switch } from '@chakra-ui/react';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
import { OSCSettings } from 'ontime-types';
import { generateId } from 'ontime-utils';
import { maybeAxiosError } from '../../../../common/api/apiUtils';
import useOscSettings, { useOscSettingsMutation } from '../../../../common/hooks-query/useOscSettings';
import { isKeyEscape } from '../../../../common/utils/keyEvent';
import { isIPAddress, isOnlyNumbers, startsWithSlash } from '../../../../common/utils/regex';
import * as Panel from '../PanelUtils';
import { cycles } from './IntegrationsPanel';
import { cycles } from './integrationUtils';
import style from './IntegrationsPanel.module.css';
const demoIntegrations = [
{ id: 1, enabled: true, cycle: 'onLoad', message: '/ontime/scene/1' },
{ id: 2, enabled: true, cycle: 'onLoad', message: '/ontime/scene/2' },
{ id: 3, enabled: true, cycle: 'onLoad', message: '/ontime/scene/3' },
{ id: 4, enabled: true, cycle: 'onLoad', message: '/ontime/scene/4' },
];
export default function OscIntegrations() {
const { data } = useOscSettings();
const { mutateAsync } = useOscSettingsMutation();
const {
control,
handleSubmit,
reset,
register,
setError,
formState: { errors, isSubmitting, isDirty, isValid },
} = useForm<OSCSettings>({
mode: 'onBlur',
defaultValues: data,
values: data,
resetOptions: {
keepDirtyValues: true,
},
});
const { fields, prepend, remove } = useFieldArray({
name: 'subscriptions',
control,
});
const onSubmit = async (values: OSCSettings) => {
if (values.portIn === values.portOut) {
setError('portIn', { message: 'OSC IN and OUT Ports cant be the same' });
return;
}
const parsedValues = { ...values, portIn: Number(values.portIn), portOut: Number(values.portOut) };
try {
await mutateAsync(parsedValues);
} catch (error) {
setError('root', { message: maybeAxiosError(error) });
}
};
const preventEscape = (event: React.KeyboardEvent) => {
if (isKeyEscape(event)) {
event.preventDefault();
event.stopPropagation();
}
};
const handleAddNewSubscription = () => {
prepend({
id: generateId(),
cycle: 'onLoad',
message: '',
enabled: false,
});
};
const handleDeleteSubscription = (index: number) => {
remove(index);
};
const canSubmit = !isSubmitting && isDirty && isValid;
return (
<Panel.Section>
<Panel.Section>
<Panel.SubHeader>Open Sound Control integrations</Panel.SubHeader>
<Panel.Section onKeyDown={preventEscape}>
<Panel.Section as='form' onSubmit={handleSubmit(onSubmit)}>
<Panel.SubHeader>
Open Sound Control settings
<div className={style.flex}>
<Button variant='ontime-ghosted' size='sm' onClick={() => reset()} isDisabled={!canSubmit}>
Reset
</Button>
<Button variant='ontime-filled' size='sm' type='submit' isDisabled={!canSubmit} isLoading={isSubmitting}>
Save changes
</Button>
</div>
</Panel.SubHeader>
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}
<Panel.ListGroup>
<Panel.ListItem>
<Panel.Field title='OSC input' description='Allow control of Ontime through OSC' />
<Switch variant='ontime' size='lg' />
<Switch variant='ontime' size='lg' {...register('enabledIn')} />
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field title='Listen on port' description='Port for incoming OSC. Default: 8888' />
<Panel.Field
title='Listen on port'
description='Port for incoming OSC. Default: 8888'
error={errors.portIn?.message}
/>
<Input
id='portIn'
placeholder='8888'
width='75px'
width='5rem'
maxLength={5}
size='sm'
textAlign='right'
maxLength={5}
variant='ontime-filled'
type='number'
autoComplete='off'
{...register('portIn', {
required: { value: true, message: 'Required field' },
max: { value: 65535, message: 'Port must be within range 1024 - 65535' },
min: { value: 1024, message: 'Port must be within range 1024 - 65535' },
pattern: {
value: isOnlyNumbers,
message: 'Value should be numeric',
},
})}
/>
</Panel.ListItem>
</Panel.ListGroup>
<Panel.ListGroup>
<Panel.ListItem>
<Panel.Field title='OSC output' description='Provide feedback from Ontime with OSC' />
<Switch variant='ontime' size='lg' />
<Switch variant='ontime' size='lg' {...register('enabledOut')} />
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field title='OSC target IP' description='IP address Ontime will send OSC messages to' />
<Panel.Field
title='OSC target IP'
description='IP address Ontime will send OSC messages to'
error={errors.targetIP?.message}
/>
<Input
id='portIn'
id='targetIP'
placeholder='127.0.0.1'
width='9rem'
size='sm'
textAlign='right'
variant='ontime-filled'
autoComplete='off'
{...register('targetIP', {
required: { value: true, message: 'Required field' },
pattern: {
value: isIPAddress,
message: 'Invalid IP address',
},
})}
/>
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field
title='OSC target port'
description='Port number Ontime will send OSC messages to'
error={errors.portOut?.message}
/>
<Input
id='portOut'
placeholder='8888'
width='75px'
size='sm'
textAlign='right'
maxLength={5}
variant='ontime-filled'
autoComplete='off'
{...register('portOut', {
required: { value: true, message: 'Required field' },
max: { value: 65535, message: 'Port must be within range 1024 - 65535' },
min: { value: 1024, message: 'Port must be within range 1024 - 65535' },
pattern: {
value: isOnlyNumbers,
message: 'Value should be numeric',
},
})}
/>
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field title='OSC target port' description='Port number Ontime will send OSC messages to' />
<Input id='portIn' placeholder='8888' width='75px' size='sm' textAlign='right' variant='ontime-filled' />
</Panel.ListItem>
</Panel.ListGroup>
</Panel.Section>
<Panel.Card>
<Panel.SubHeader>
OSC Integration
<Button variant='ontime-subtle' size='sm' rightIcon={<IoAdd />} onClick={() => undefined}>
<Button variant='ontime-subtle' size='sm' rightIcon={<IoAdd />} onClick={handleAddNewSubscription}>
New
</Button>
</Panel.SubHeader>
<Panel.Table>
<thead>
<tr>
<th>Enabled</th>
<th>Cycle</th>
<th className={style.fullWidth}>Message</th>
<th />
</tr>
</thead>
<tbody>
{demoIntegrations.map((integration) => (
<tr key={integration.id}>
<td>
<Switch variant='ontime' />
</td>
<td className={style.autoWidth}>
<Select size='sm' variant='ontime' className={style.fitContents}>
{cycles.map((cycle) => (
<option key={cycle.id} value={cycle.value}>
{cycle.label}
</option>
))}
</Select>
</td>
<td className={style.fullWidth}>
<Input size='sm' variant='ontime-filled' value={integration.message} />
</td>
<td>
<IconButton
size='sm'
variant='ontime-ghosted'
color='#FA5656' // $red-500
icon={<IoTrash />}
aria-label='Delete entry'
/>
</td>
{fields.length > 0 && (
<Panel.Table>
<thead>
<tr>
<th>Enabled</th>
<th>Cycle</th>
<th className={style.fullWidth}>Message</th>
<th />
</tr>
))}
</tbody>
</Panel.Table>
</thead>
<tbody>
{fields.map((field, index) => {
// @ts-expect-error -- not sure why it is not finding the type, it is ok
const maybeError = errors.subscriptions?.[index]?.message?.message;
return (
<tr key={field.id}>
<td>
<Switch variant='ontime' {...register(`subscriptions.${index}.enabled`)} />
</td>
<td className={style.autoWidth}>
<Select
size='sm'
variant='ontime'
className={style.fitContents}
{...register(`subscriptions.${index}.cycle`)}
>
{cycles.map((cycle) => (
<option key={cycle.id} value={cycle.value}>
{cycle.label}
</option>
))}
</Select>
</td>
<td className={style.fullWidth}>
<Input
key={field.id}
size='sm'
variant='ontime-filled'
autoComplete='off'
placeholder='/from-ontime/{{timer.current}}'
{...register(`subscriptions.${index}.message`, {
required: { value: true, message: 'Required field' },
pattern: {
value: startsWithSlash,
message: 'OSC messages should start with a forward slash',
},
})}
/>
{maybeError && <Panel.Error>{maybeError}</Panel.Error>}
</td>
<td>
<IconButton
size='sm'
variant='ontime-ghosted'
color='#FA5656' // $red-500
icon={<IoTrash />}
aria-label='Delete entry'
onClick={() => handleDeleteSubscription(index)}
/>
</td>
</tr>
);
})}
</tbody>
</Panel.Table>
)}
</Panel.Card>
</Panel.Section>
);
@@ -0,0 +1,8 @@
export const cycles = [
{ id: 1, label: 'On Load', value: 'onLoad' },
{ id: 2, label: 'On Start', value: 'onStart' },
{ id: 3, label: 'On Pause', value: 'onPause' },
{ id: 4, label: 'On Stop', value: 'onStop' },
{ id: 5, label: 'Every second', value: 'onUpdate' },
{ id: 6, label: 'On Finish', value: 'onFinish' },
];
@@ -1,5 +1,4 @@
import { IconButton } from '@chakra-ui/react';
import { Alert, AlertDescription, AlertIcon } from '@chakra-ui/react';
import { Alert, AlertDescription, AlertIcon, IconButton } from '@chakra-ui/react';
import { IoPencil } from '@react-icons/all-files/io5/IoPencil';
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';