mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-17 13:23:35 +00:00
@@ -7,7 +7,6 @@ import {
|
||||
MessageResponse,
|
||||
OntimeRundown,
|
||||
OSCSettings,
|
||||
OscSubscription,
|
||||
ProjectData,
|
||||
ProjectFileListResponse,
|
||||
Settings,
|
||||
@@ -107,6 +106,14 @@ export async function getOSC(): Promise<OSCSettings> {
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description HTTP request to mutate osc settings
|
||||
* @return {Promise}
|
||||
*/
|
||||
export async function postOSC(data: OSCSettings): Promise<AxiosResponse<OSCSettings>> {
|
||||
return axios.post(`${ontimeURL}/osc`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description HTTP request to retrieve http settings
|
||||
* @return {Promise}
|
||||
@@ -120,26 +127,10 @@ export async function getHTTP(): Promise<HttpSettings> {
|
||||
* @description HTTP request to mutate http settings
|
||||
* @return {Promise}
|
||||
*/
|
||||
export async function postHTTP(data: HttpSettings) {
|
||||
export async function postHTTP(data: HttpSettings): Promise<AxiosResponse<HttpSettings>> {
|
||||
return axios.post(`${ontimeURL}/http`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description HTTP request to mutate osc settings
|
||||
* @return {Promise}
|
||||
*/
|
||||
export async function postOSC(data: OSCSettings) {
|
||||
return axios.post(`${ontimeURL}/osc`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description HTTP request to mutate osc subscriptions
|
||||
* @return {Promise}
|
||||
*/
|
||||
export async function postOscSubscriptions(data: OscSubscription) {
|
||||
return axios.post(`${ontimeURL}/osc-subscriptions`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description HTTP request to download db in CSV format
|
||||
*/
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { HttpSettings } from 'ontime-types';
|
||||
|
||||
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
||||
import { HTTP_SETTINGS } from '../api/apiConstants';
|
||||
@@ -20,13 +19,16 @@ export function useHttpSettings() {
|
||||
});
|
||||
|
||||
// we need to jump through some hoops because of the type op port
|
||||
return { data: data! as unknown as HttpSettings, status, isFetching, isError, refetch };
|
||||
return { data: data ?? httpPlaceholder, status, isFetching, isError, refetch };
|
||||
}
|
||||
|
||||
export function usePostHttpSettings() {
|
||||
const { isPending, mutateAsync } = useMutation({
|
||||
mutationFn: postHTTP,
|
||||
onError: (error) => logAxiosError('Error saving HTTP settings', error),
|
||||
onSuccess: (res) => {
|
||||
ontimeQueryClient.setQueryData(HTTP_SETTINGS, res.data);
|
||||
},
|
||||
onSettled: () => ontimeQueryClient.invalidateQueries({ queryKey: HTTP_SETTINGS }),
|
||||
});
|
||||
return { isPending, mutateAsync };
|
||||
|
||||
@@ -3,17 +3,14 @@ import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
||||
import { OSC_SETTINGS } from '../api/apiConstants';
|
||||
import { logAxiosError } from '../api/apiUtils';
|
||||
import { getOSC, postOSC, postOscSubscriptions } from '../api/ontimeApi';
|
||||
import { getOSC, postOSC } from '../api/ontimeApi';
|
||||
import { oscPlaceholderSettings } from '../models/OscSettings';
|
||||
import { ontimeQueryClient } from '../queryClient';
|
||||
|
||||
export default function useOscSettings() {
|
||||
const { data, status, isFetching, isError, refetch } = useQuery({
|
||||
queryKey: OSC_SETTINGS,
|
||||
queryFn: async () => {
|
||||
const oscData = await getOSC();
|
||||
return { ...oscData, portIn: String(oscData.portIn), portOut: String(oscData.portOut) };
|
||||
},
|
||||
queryFn: getOSC,
|
||||
placeholderData: oscPlaceholderSettings,
|
||||
retry: 5,
|
||||
retryDelay: (attempt: number) => attempt * 2500,
|
||||
@@ -28,16 +25,9 @@ export function useOscSettingsMutation() {
|
||||
const { isPending, mutateAsync } = useMutation({
|
||||
mutationFn: postOSC,
|
||||
onError: (error) => logAxiosError('Error saving OSC settings', error),
|
||||
onSuccess: (res) => ontimeQueryClient.setQueryData(OSC_SETTINGS, res.data),
|
||||
onSettled: () => ontimeQueryClient.invalidateQueries({ queryKey: OSC_SETTINGS }),
|
||||
});
|
||||
return { isPending, mutateAsync };
|
||||
}
|
||||
|
||||
export function usePostOscSubscriptions() {
|
||||
const { isPending, mutateAsync } = useMutation({
|
||||
mutationFn: postOscSubscriptions,
|
||||
onError: (error) => logAxiosError('Error saving OSC settings', error),
|
||||
onSuccess: (res) => {
|
||||
ontimeQueryClient.setQueryData(OSC_SETTINGS, res.data);
|
||||
},
|
||||
onSettled: () => ontimeQueryClient.invalidateQueries({ queryKey: OSC_SETTINGS }),
|
||||
});
|
||||
return { isPending, mutateAsync };
|
||||
|
||||
@@ -2,12 +2,5 @@ import { HttpSettings } from 'ontime-types';
|
||||
|
||||
export const httpPlaceholder: HttpSettings = {
|
||||
enabledOut: false,
|
||||
subscriptions: {
|
||||
onLoad: [],
|
||||
onStart: [],
|
||||
onUpdate: [],
|
||||
onPause: [],
|
||||
onStop: [],
|
||||
onFinish: [],
|
||||
},
|
||||
subscriptions: [],
|
||||
};
|
||||
|
||||
@@ -1,20 +1,6 @@
|
||||
import { GetInfo, OSCSettings } from 'ontime-types';
|
||||
import { GetInfo } from 'ontime-types';
|
||||
|
||||
export const oscPlaceholderSettings: OSCSettings = {
|
||||
portIn: 0,
|
||||
portOut: 0,
|
||||
targetIP: '',
|
||||
enabledIn: false,
|
||||
enabledOut: false,
|
||||
subscriptions: {
|
||||
onLoad: [],
|
||||
onStart: [],
|
||||
onPause: [],
|
||||
onStop: [],
|
||||
onUpdate: [],
|
||||
onFinish: [],
|
||||
},
|
||||
};
|
||||
import { oscPlaceholderSettings } from './OscSettings';
|
||||
|
||||
export const ontimePlaceholderInfo: GetInfo = {
|
||||
networkInterfaces: [],
|
||||
|
||||
@@ -1,23 +1,10 @@
|
||||
import { OSCSettings } from 'ontime-types';
|
||||
|
||||
// in the placeholder, we pass strings to satisfy input type
|
||||
export interface PlaceholderSettings extends Omit<OSCSettings, 'portIn' | 'portOut'> {
|
||||
portIn: string;
|
||||
portOut: string;
|
||||
}
|
||||
|
||||
export const oscPlaceholderSettings: PlaceholderSettings = {
|
||||
portIn: '',
|
||||
portOut: '',
|
||||
targetIP: '',
|
||||
export const oscPlaceholderSettings: OSCSettings = {
|
||||
portIn: 8888,
|
||||
portOut: 9999,
|
||||
targetIP: '127.0.0.1',
|
||||
enabledIn: false,
|
||||
enabledOut: false,
|
||||
subscriptions: {
|
||||
onLoad: [],
|
||||
onStart: [],
|
||||
onPause: [],
|
||||
onStop: [],
|
||||
onUpdate: [],
|
||||
onFinish: [],
|
||||
},
|
||||
subscriptions: [],
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { isIPAddress, isOnlyNumbers, startsWithHttp } from '../regex';
|
||||
import { isIPAddress, isOnlyNumbers, startsWithHttp, startsWithSlash } from '../regex';
|
||||
|
||||
describe('simple tests for regex', () => {
|
||||
test('isOnlyNumbers', () => {
|
||||
@@ -36,4 +36,16 @@ describe('simple tests for regex', () => {
|
||||
expect(startsWithHttp.test(t)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test('startsWithSlash', () => {
|
||||
const right = ['//test'];
|
||||
const wrong = ['testing', '123.0.1'];
|
||||
|
||||
right.forEach((t) => {
|
||||
expect(startsWithSlash.test(t)).toBe(true);
|
||||
});
|
||||
wrong.forEach((t) => {
|
||||
expect(startsWithSlash.test(t)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,3 +3,7 @@ import { KeyboardEvent } from 'react';
|
||||
export function isKeyEnter<T>(event: KeyboardEvent<T>): boolean {
|
||||
return event.key === 'Enter';
|
||||
}
|
||||
|
||||
export function isKeyEscape<T>(event: KeyboardEvent<T>): boolean {
|
||||
return event.key === 'Escape';
|
||||
}
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* Simple regex patterns for common use cases
|
||||
* mostly used in form validation
|
||||
*/
|
||||
|
||||
export const isOnlyNumbers = /^\d+$/;
|
||||
export const isIPAddress = /^((25[0-5]|(2[0-4]|1\d|[1-9]|)\d)\.?\b){4}$/;
|
||||
export const startsWithHttp = /^http:\/\//;
|
||||
export const startsWithSlash = /^\//;
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
+141
-52
@@ -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
@@ -5,3 +5,8 @@
|
||||
.fitContents {
|
||||
width: max-content !important; /* override chakra */
|
||||
}
|
||||
|
||||
.flex {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
+16
-8
@@ -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 />
|
||||
</>
|
||||
|
||||
+207
-63
@@ -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
-2
@@ -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';
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ import styles from './Editor.module.scss';
|
||||
const Rundown = lazy(() => import('../rundown/RundownExport'));
|
||||
const TimerControl = lazy(() => import('../control/playback/TimerControlExport'));
|
||||
const MessageControl = lazy(() => import('../control/message/MessageControlExport'));
|
||||
const IntegrationModal = lazy(() => import('../modals/integration-modal/IntegrationModal'));
|
||||
const SettingsModal = lazy(() => import('../modals/settings-modal/SettingsModal'));
|
||||
|
||||
export default function Editor() {
|
||||
@@ -27,11 +26,6 @@ export default function Editor() {
|
||||
|
||||
const { isOpen: isOldSettingsOpen, onOpen: onSettingsOpen, onClose: onSettingsClose } = useDisclosure();
|
||||
const { isOpen: isUploadModalOpen, onOpen: onUploadModalOpen, onClose: onUploadModalClose } = useDisclosure();
|
||||
const {
|
||||
isOpen: isIntegrationModalOpen,
|
||||
onOpen: onIntegrationModalOpen,
|
||||
onClose: onIntegrationModalClose,
|
||||
} = useDisclosure();
|
||||
const { isOpen: isSheetsOpen, onOpen: onSheetsOpen, onClose: onSheetsClose } = useDisclosure();
|
||||
|
||||
// Set window title
|
||||
@@ -45,7 +39,6 @@ export default function Editor() {
|
||||
<>
|
||||
<ErrorBoundary>
|
||||
<UploadModal onClose={onUploadModalClose} isOpen={isUploadModalOpen} />
|
||||
<IntegrationModal onClose={onIntegrationModalClose} isOpen={isIntegrationModalOpen} />
|
||||
<SettingsModal isOpen={isOldSettingsOpen} onClose={onSettingsClose} />
|
||||
<SheetsModal onClose={onSheetsClose} isOpen={isSheetsOpen} />
|
||||
</ErrorBoundary>
|
||||
@@ -57,8 +50,6 @@ export default function Editor() {
|
||||
onSettingsClose={onSettingsClose}
|
||||
isUploadOpen={isUploadModalOpen}
|
||||
onUploadOpen={onUploadModalOpen}
|
||||
isIntegrationOpen={isIntegrationModalOpen}
|
||||
onIntegrationOpen={onIntegrationModalOpen}
|
||||
openSettings={handleSettings}
|
||||
isSettingsOpen={isSettingsOpen}
|
||||
isSheetsOpen={isSheetsOpen}
|
||||
|
||||
@@ -3,8 +3,6 @@ import { IconButton, MenuButton, Tooltip } from '@chakra-ui/react';
|
||||
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
|
||||
import { IoCloud } from '@react-icons/all-files/io5/IoCloud';
|
||||
import { IoCloudOutline } from '@react-icons/all-files/io5/IoCloudOutline';
|
||||
import { IoExtensionPuzzle } from '@react-icons/all-files/io5/IoExtensionPuzzle';
|
||||
import { IoExtensionPuzzleOutline } from '@react-icons/all-files/io5/IoExtensionPuzzleOutline';
|
||||
import { IoOptions } from '@react-icons/all-files/io5/IoOptions';
|
||||
import { IoPlay } from '@react-icons/all-files/io5/IoPlay';
|
||||
import { IoPushOutline } from '@react-icons/all-files/io5/IoPushOutline';
|
||||
@@ -27,8 +25,6 @@ interface MenuBarProps {
|
||||
onSettingsClose: () => void;
|
||||
isUploadOpen: boolean;
|
||||
onUploadOpen: () => void;
|
||||
isIntegrationOpen: boolean;
|
||||
onIntegrationOpen: () => void;
|
||||
isSheetsOpen: boolean;
|
||||
onSheetsOpen: () => void;
|
||||
openSettings: (newTab?: string) => void;
|
||||
@@ -54,8 +50,6 @@ const MenuBar = (props: MenuBarProps) => {
|
||||
onSettingsClose,
|
||||
isUploadOpen,
|
||||
onUploadOpen,
|
||||
isIntegrationOpen,
|
||||
onIntegrationOpen,
|
||||
openSettings,
|
||||
isSettingsOpen,
|
||||
isSheetsOpen,
|
||||
@@ -156,15 +150,6 @@ const MenuBar = (props: MenuBarProps) => {
|
||||
tooltip='Sheets'
|
||||
aria-label='Sheets'
|
||||
/>
|
||||
<TooltipActionBtn
|
||||
{...buttonStyle}
|
||||
isDisabled={appMode === AppMode.Run}
|
||||
icon={isIntegrationOpen ? <IoExtensionPuzzle /> : <IoExtensionPuzzleOutline />}
|
||||
className={isIntegrationOpen ? style.open : ''}
|
||||
clickHandler={onIntegrationOpen}
|
||||
tooltip='Integrations'
|
||||
aria-label='Integrations'
|
||||
/>
|
||||
<TooltipActionBtn
|
||||
{...buttonStyle}
|
||||
isDisabled={appMode === AppMode.Run}
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
import { ModalBody, Tab, TabList, TabPanel, TabPanels, Tabs } from '@chakra-ui/react';
|
||||
|
||||
import ModalWrapper from '../ModalWrapper';
|
||||
|
||||
import HttpIntegration from './http/HttpIntegration';
|
||||
import OscIntegration from './osc/OscIntegration';
|
||||
import OscSettings from './osc/OscSettings';
|
||||
|
||||
import styles from '../Modal.module.scss';
|
||||
|
||||
interface IntegrationModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const oscDocsUrl = 'https://ontime.gitbook.io/v2/control-and-feedback/integrations';
|
||||
|
||||
export default function IntegrationModal(props: IntegrationModalProps) {
|
||||
const { isOpen, onClose } = props;
|
||||
|
||||
return (
|
||||
<ModalWrapper title='Integration Settings' isOpen={isOpen} onClose={onClose}>
|
||||
<ModalBody>
|
||||
<div className={styles.headerNotes}>
|
||||
Manage settings related to protocol integrations
|
||||
<a href={oscDocsUrl} target='_blank' rel='noreferrer'>
|
||||
Read the docs
|
||||
</a>
|
||||
</div>
|
||||
<Tabs variant='ontime' size='sm' isLazy>
|
||||
<TabList>
|
||||
<Tab>OSC</Tab>
|
||||
<Tab>OSC Integration</Tab>
|
||||
<Tab>HTTP Integration</Tab>
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
<TabPanel>
|
||||
<OscSettings />
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<OscIntegration />
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<HttpIntegration />
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</ModalBody>
|
||||
</ModalWrapper>
|
||||
);
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { Switch } from '@chakra-ui/react';
|
||||
import type { HttpSettings } from 'ontime-types';
|
||||
import { TimerLifeCycle } from 'ontime-types';
|
||||
|
||||
import { useHttpSettings, usePostHttpSettings } from '../../../../common/hooks-query/useHttpSettings';
|
||||
import { useEmitLog } from '../../../../common/stores/logger';
|
||||
import ModalLoader from '../../modal-loader/ModalLoader';
|
||||
import OntimeModalFooter from '../../OntimeModalFooter';
|
||||
import { OntimeCycle, sectionText } from '../integration.utils';
|
||||
|
||||
import HttpSubscriptionRow from './HttpSubscriptionRow';
|
||||
|
||||
import styles from '../../Modal.module.scss';
|
||||
|
||||
export default function HttpIntegration() {
|
||||
const { data, isFetching } = useHttpSettings();
|
||||
const { mutateAsync } = usePostHttpSettings();
|
||||
const { emitError } = useEmitLog();
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
register,
|
||||
reset,
|
||||
formState: { isSubmitting, isDirty, isValid },
|
||||
} = useForm<HttpSettings>({
|
||||
mode: 'onBlur',
|
||||
defaultValues: data,
|
||||
values: data,
|
||||
resetOptions: {
|
||||
keepDirtyValues: true,
|
||||
},
|
||||
});
|
||||
|
||||
const [showSection, setShowSection] = useState<OntimeCycle>(TimerLifeCycle.onLoad);
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
reset(data);
|
||||
}
|
||||
}, [data, reset]);
|
||||
|
||||
const resetForm = () => {
|
||||
reset(data);
|
||||
};
|
||||
|
||||
const onSubmit = async (values: HttpSettings) => {
|
||||
try {
|
||||
const newSettings: HttpSettings = {
|
||||
enabledOut: Boolean(values.enabledOut),
|
||||
subscriptions: {
|
||||
onLoad: values.subscriptions.onLoad ?? [],
|
||||
onStart: values.subscriptions.onStart ?? [],
|
||||
onPause: values.subscriptions.onPause ?? [],
|
||||
onStop: values.subscriptions.onStop ?? [],
|
||||
onUpdate: values.subscriptions.onUpdate ?? [],
|
||||
onFinish: values.subscriptions.onFinish ?? [],
|
||||
},
|
||||
};
|
||||
|
||||
await mutateAsync(newSettings);
|
||||
} catch (error) {
|
||||
emitError(`Error setting HTML: ${error}`);
|
||||
}
|
||||
};
|
||||
|
||||
if (isFetching) {
|
||||
return <ModalLoader />;
|
||||
}
|
||||
|
||||
const placeholder = 'http://x.x.x.x:xxxx/api/path';
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className={styles.sectionContainer} id='http-subscriptions'>
|
||||
<div className={styles.splitSection}>
|
||||
<div>
|
||||
<span className={`${styles.sectionTitle} ${styles.main}`}>HTTP Output</span>
|
||||
<span className={styles.sectionSubtitle}>Ontime data feedback</span>
|
||||
</div>
|
||||
<Switch {...register('enabledOut')} variant='ontime-on-light' />
|
||||
</div>
|
||||
|
||||
<HttpSubscriptionRow
|
||||
cycle={TimerLifeCycle.onLoad}
|
||||
title={sectionText.onLoad.title}
|
||||
subtitle={sectionText.onLoad.subtitle}
|
||||
visible={showSection === TimerLifeCycle.onLoad}
|
||||
setShowSection={setShowSection}
|
||||
register={register}
|
||||
control={control}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
<HttpSubscriptionRow
|
||||
cycle={TimerLifeCycle.onStart}
|
||||
title={sectionText.onStart.title}
|
||||
subtitle={sectionText.onStart.subtitle}
|
||||
visible={showSection === TimerLifeCycle.onStart}
|
||||
setShowSection={setShowSection}
|
||||
register={register}
|
||||
control={control}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
<HttpSubscriptionRow
|
||||
cycle={TimerLifeCycle.onPause}
|
||||
title={sectionText.onPause.title}
|
||||
subtitle={sectionText.onPause.subtitle}
|
||||
visible={showSection === TimerLifeCycle.onPause}
|
||||
setShowSection={setShowSection}
|
||||
register={register}
|
||||
control={control}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
<HttpSubscriptionRow
|
||||
cycle={TimerLifeCycle.onStop}
|
||||
title={sectionText.onStop.title}
|
||||
subtitle={sectionText.onStop.subtitle}
|
||||
visible={showSection === TimerLifeCycle.onStop}
|
||||
setShowSection={setShowSection}
|
||||
register={register}
|
||||
control={control}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
<HttpSubscriptionRow
|
||||
cycle={TimerLifeCycle.onUpdate}
|
||||
title={sectionText.onUpdate.title}
|
||||
subtitle={sectionText.onUpdate.subtitle}
|
||||
visible={showSection === TimerLifeCycle.onUpdate}
|
||||
setShowSection={setShowSection}
|
||||
register={register}
|
||||
control={control}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
<HttpSubscriptionRow
|
||||
cycle={TimerLifeCycle.onFinish}
|
||||
title={sectionText.onFinish.title}
|
||||
subtitle={sectionText.onFinish.subtitle}
|
||||
visible={showSection === TimerLifeCycle.onFinish}
|
||||
setShowSection={setShowSection}
|
||||
register={register}
|
||||
control={control}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
<OntimeModalFooter
|
||||
formId='http-subscriptions'
|
||||
handleRevert={resetForm}
|
||||
isDirty={isDirty}
|
||||
isValid={isValid}
|
||||
isSubmitting={isSubmitting}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
import { Control, useFieldArray, UseFormRegister } from 'react-hook-form';
|
||||
import { Button, IconButton, Input, Switch } from '@chakra-ui/react';
|
||||
import { FiChevronUp } from '@react-icons/all-files/fi/FiChevronUp';
|
||||
import { IoRemove } from '@react-icons/all-files/io5/IoRemove';
|
||||
import { HttpSettings, TimerLifeCycle } from 'ontime-types';
|
||||
|
||||
import { useEmitLog } from '../../../../common/stores/logger';
|
||||
import { startsWithHttp } from '../../../../common/utils/regex';
|
||||
|
||||
import styles from '../../Modal.module.scss';
|
||||
|
||||
interface SubscriptionRowProps {
|
||||
cycle: TimerLifeCycle;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
visible: boolean;
|
||||
setShowSection: (cycle: TimerLifeCycle) => void;
|
||||
register: UseFormRegister<HttpSettings>;
|
||||
control: Control<HttpSettings>;
|
||||
placeholder: string;
|
||||
}
|
||||
|
||||
export default function SubscriptionRow(props: SubscriptionRowProps) {
|
||||
const { cycle, title, subtitle, visible, setShowSection, register, control, placeholder } = props;
|
||||
const { emitError } = useEmitLog();
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
name: `subscriptions.${cycle}`,
|
||||
control,
|
||||
});
|
||||
|
||||
const hasTooManyOptions = fields.length >= 3;
|
||||
const headerStyle = `${styles.splitSection} ${visible ? '' : styles.showPointer}`;
|
||||
|
||||
const sectionTitle = `${title} ${fields.length ? fields.length : '-'} / 3`;
|
||||
|
||||
const handleAddNew = () => {
|
||||
if (hasTooManyOptions) {
|
||||
emitError(`Maximum amount of ${cycle} subscriptions reached (3)`);
|
||||
return;
|
||||
}
|
||||
append({
|
||||
message: '',
|
||||
enabled: false,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={headerStyle} onClick={() => setShowSection(cycle)}>
|
||||
<div>
|
||||
<span className={`${styles.sectionTitle} ${styles.main}`}>{sectionTitle}</span>
|
||||
{visible && <span className={styles.sectionSubtitle}>{subtitle}</span>}
|
||||
</div>
|
||||
<FiChevronUp />
|
||||
</div>
|
||||
{visible && (
|
||||
<>
|
||||
{fields.map((subscription, index) => (
|
||||
<div key={subscription.id} className={styles.entryRow}>
|
||||
<IconButton
|
||||
icon={<IoRemove />}
|
||||
onClick={() => remove(index)}
|
||||
aria-label='delete'
|
||||
size='xs'
|
||||
colorScheme='red'
|
||||
/>
|
||||
<Input
|
||||
placeholder={placeholder}
|
||||
size='xs'
|
||||
variant='ontime-filled-on-light'
|
||||
autoComplete='off'
|
||||
{...register(`subscriptions.${cycle}.${index}.message`, {
|
||||
pattern: { value: startsWithHttp, message: 'Request address must start with http://' },
|
||||
})}
|
||||
/>
|
||||
<Switch variant='ontime-on-light' {...register(`subscriptions.${cycle}.${index}.enabled`)} />
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
onClick={handleAddNew}
|
||||
className={styles.shiftRight}
|
||||
isDisabled={hasTooManyOptions}
|
||||
size='xs'
|
||||
colorScheme='blue'
|
||||
variant='outline'
|
||||
padding='0 2em'
|
||||
>
|
||||
Add new
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { TimerLifeCycle } from 'ontime-types';
|
||||
|
||||
export type OntimeCycle = keyof typeof TimerLifeCycle;
|
||||
|
||||
export const sectionText: { [key in TimerLifeCycle]: { title: string; subtitle: string } } = {
|
||||
onLoad: {
|
||||
title: 'On Load',
|
||||
subtitle: 'Triggers when a timer is loaded',
|
||||
},
|
||||
onStart: {
|
||||
title: 'On Start',
|
||||
subtitle: 'Triggers when a timer starts',
|
||||
},
|
||||
onPause: {
|
||||
title: 'On Pause',
|
||||
subtitle: 'Triggers when a running timer is paused',
|
||||
},
|
||||
onStop: {
|
||||
title: 'On Stop',
|
||||
subtitle: 'Triggers when a running timer is stopped',
|
||||
},
|
||||
onUpdate: {
|
||||
title: 'On Every Second',
|
||||
subtitle: 'Triggers when a running timer is updated (at least once a second, can be more)',
|
||||
},
|
||||
onFinish: {
|
||||
title: 'On Finish',
|
||||
subtitle: 'Triggers when a running reaches 0',
|
||||
},
|
||||
};
|
||||
@@ -1,139 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import type { OscSubscription } from 'ontime-types';
|
||||
import { TimerLifeCycle } from 'ontime-types';
|
||||
|
||||
import useOscSettings, { usePostOscSubscriptions } from '../../../../common/hooks-query/useOscSettings';
|
||||
import { useEmitLog } from '../../../../common/stores/logger';
|
||||
import ModalLoader from '../../modal-loader/ModalLoader';
|
||||
import OntimeModalFooter from '../../OntimeModalFooter';
|
||||
import { type OntimeCycle, sectionText } from '../integration.utils';
|
||||
|
||||
import OscSubscriptionRow from './OscSubscriptionRow';
|
||||
|
||||
import styles from '../../Modal.module.scss';
|
||||
|
||||
export default function OscIntegration() {
|
||||
const { data, isFetching } = useOscSettings();
|
||||
const { mutateAsync } = usePostOscSubscriptions();
|
||||
const { emitError } = useEmitLog();
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
register,
|
||||
reset,
|
||||
formState: { isSubmitting, isDirty, isValid },
|
||||
} = useForm<OscSubscription>({
|
||||
defaultValues: data.subscriptions,
|
||||
values: data.subscriptions,
|
||||
resetOptions: {
|
||||
keepDirtyValues: true,
|
||||
},
|
||||
});
|
||||
|
||||
const [showSection, setShowSection] = useState<OntimeCycle>(TimerLifeCycle.onLoad);
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
reset(data.subscriptions);
|
||||
}
|
||||
}, [data, reset]);
|
||||
|
||||
const resetForm = () => {
|
||||
reset(data.subscriptions);
|
||||
};
|
||||
|
||||
const onSubmit = async (values: OscSubscription) => {
|
||||
try {
|
||||
const subscriptions = {
|
||||
onLoad: values.onLoad ?? [],
|
||||
onStart: values.onStart ?? [],
|
||||
onPause: values.onPause ?? [],
|
||||
onStop: values.onStop ?? [],
|
||||
onUpdate: values.onUpdate ?? [],
|
||||
onFinish: values.onFinish ?? [],
|
||||
};
|
||||
|
||||
await mutateAsync(subscriptions);
|
||||
} catch (error) {
|
||||
emitError(`Error setting OSC: ${error}`);
|
||||
}
|
||||
};
|
||||
|
||||
if (isFetching) {
|
||||
return <ModalLoader />;
|
||||
}
|
||||
|
||||
const placeholder = 'OSC message';
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className={styles.sectionContainer} id='osc-subscriptions'>
|
||||
<OscSubscriptionRow
|
||||
cycle={TimerLifeCycle.onLoad}
|
||||
title={sectionText.onLoad.title}
|
||||
subtitle={sectionText.onLoad.subtitle}
|
||||
visible={showSection === TimerLifeCycle.onLoad}
|
||||
setShowSection={setShowSection}
|
||||
register={register}
|
||||
control={control}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
<OscSubscriptionRow
|
||||
cycle={TimerLifeCycle.onStart}
|
||||
title={sectionText.onStart.title}
|
||||
subtitle={sectionText.onStart.subtitle}
|
||||
visible={showSection === TimerLifeCycle.onStart}
|
||||
setShowSection={setShowSection}
|
||||
register={register}
|
||||
control={control}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
<OscSubscriptionRow
|
||||
cycle={TimerLifeCycle.onPause}
|
||||
title={sectionText.onPause.title}
|
||||
subtitle={sectionText.onPause.subtitle}
|
||||
visible={showSection === TimerLifeCycle.onPause}
|
||||
setShowSection={setShowSection}
|
||||
register={register}
|
||||
control={control}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
<OscSubscriptionRow
|
||||
cycle={TimerLifeCycle.onStop}
|
||||
title={sectionText.onStop.title}
|
||||
subtitle={sectionText.onStop.subtitle}
|
||||
visible={showSection === TimerLifeCycle.onStop}
|
||||
setShowSection={setShowSection}
|
||||
register={register}
|
||||
control={control}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
<OscSubscriptionRow
|
||||
cycle={TimerLifeCycle.onUpdate}
|
||||
title={sectionText.onUpdate.title}
|
||||
subtitle={sectionText.onUpdate.subtitle}
|
||||
visible={showSection === TimerLifeCycle.onUpdate}
|
||||
setShowSection={setShowSection}
|
||||
register={register}
|
||||
control={control}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
<OscSubscriptionRow
|
||||
cycle={TimerLifeCycle.onFinish}
|
||||
title={sectionText.onFinish.title}
|
||||
subtitle={sectionText.onFinish.subtitle}
|
||||
visible={showSection === TimerLifeCycle.onFinish}
|
||||
setShowSection={setShowSection}
|
||||
register={register}
|
||||
control={control}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
<OntimeModalFooter
|
||||
formId='osc-subscriptions'
|
||||
handleRevert={resetForm}
|
||||
isDirty={isDirty}
|
||||
isValid={isValid}
|
||||
isSubmitting={isSubmitting}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { FormControl, Input, Switch } from '@chakra-ui/react';
|
||||
|
||||
import useOscSettings, { useOscSettingsMutation } from '../../../../common/hooks-query/useOscSettings';
|
||||
import { PlaceholderSettings } from '../../../../common/models/OscSettings';
|
||||
import { useEmitLog } from '../../../../common/stores/logger';
|
||||
import { isIPAddress, isOnlyNumbers } from '../../../../common/utils/regex';
|
||||
import ModalLoader from '../../modal-loader/ModalLoader';
|
||||
import OntimeModalFooter from '../../OntimeModalFooter';
|
||||
|
||||
import styles from '../../Modal.module.scss';
|
||||
|
||||
export default function OscSettings() {
|
||||
const { data, isFetching } = useOscSettings();
|
||||
const { mutateAsync } = useOscSettingsMutation();
|
||||
const { emitError } = useEmitLog();
|
||||
const {
|
||||
handleSubmit,
|
||||
register,
|
||||
reset,
|
||||
setError,
|
||||
formState: { errors, isSubmitting, isDirty, isValid },
|
||||
} = useForm<PlaceholderSettings>({
|
||||
defaultValues: data,
|
||||
values: data,
|
||||
resetOptions: {
|
||||
keepDirtyValues: true,
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
reset(data);
|
||||
}
|
||||
}, [data, reset]);
|
||||
const onSubmit = async (values: PlaceholderSettings) => {
|
||||
const numericPortIn = Number(values.portIn);
|
||||
const numericPortOut = Number(values.portOut);
|
||||
|
||||
if (numericPortIn === numericPortOut) {
|
||||
setError('portIn', { message: 'OSC IN and OUT Ports cant be the same' });
|
||||
return;
|
||||
}
|
||||
|
||||
const parsedValues = {
|
||||
...values,
|
||||
portIn: numericPortIn,
|
||||
portOut: numericPortOut,
|
||||
};
|
||||
|
||||
try {
|
||||
await mutateAsync(parsedValues);
|
||||
} catch (error) {
|
||||
emitError(`Error setting OSC: ${error}`);
|
||||
}
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
reset(data);
|
||||
};
|
||||
|
||||
if (isFetching) {
|
||||
return <ModalLoader />;
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className={styles.sectionContainer} id='oscSettings'>
|
||||
<div className={styles.splitSection}>
|
||||
<div>
|
||||
<span className={`${styles.sectionTitle} ${styles.main}`}>OSC Input</span>
|
||||
<span className={styles.sectionSubtitle}>Control Ontime with OSC</span>
|
||||
</div>
|
||||
<Switch {...register('enabledIn')} variant='ontime-on-light' />
|
||||
</div>
|
||||
|
||||
<FormControl isInvalid={!!errors.portIn} className={styles.splitSection}>
|
||||
<label htmlFor='portIn'>
|
||||
<span className={styles.sectionTitle}>Listen on Port</span>
|
||||
{errors.portIn ? (
|
||||
<span className={styles.error}>{errors.portIn.message}</span>
|
||||
) : (
|
||||
<span className={styles.sectionSubtitle}>Default 8888</span>
|
||||
)}
|
||||
</label>
|
||||
<Input
|
||||
id='portIn'
|
||||
placeholder='8888'
|
||||
width='75px'
|
||||
size='sm'
|
||||
textAlign='right'
|
||||
maxLength={5}
|
||||
variant='ontime-filled-on-light'
|
||||
{...register('portIn', {
|
||||
required: { value: true, message: 'Required field' },
|
||||
max: { value: 65535, message: 'Port in incorrect range (1024 - 65535)' },
|
||||
min: { value: 1024, message: 'Port in incorrect range (1024 - 65535)' },
|
||||
pattern: {
|
||||
value: isOnlyNumbers,
|
||||
message: 'Value should be numeric',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
</FormControl>
|
||||
<div style={{ height: '16px' }} />
|
||||
<div className={styles.splitSection}>
|
||||
<div>
|
||||
<span className={styles.sectionTitle} style={{ fontWeight: 600 }}>
|
||||
OSC Output
|
||||
</span>
|
||||
<span className={styles.sectionSubtitle}>Ontime data feedback</span>
|
||||
</div>
|
||||
<Switch {...register('enabledOut')} variant='ontime-on-light' />
|
||||
</div>
|
||||
|
||||
<FormControl isInvalid={!!errors.targetIP} className={styles.splitSection}>
|
||||
<label htmlFor='targetIP'>
|
||||
<span className={styles.sectionTitle}>OSC target IP</span>
|
||||
{errors.targetIP ? (
|
||||
<span className={styles.error}>{errors.targetIP.message}</span>
|
||||
) : (
|
||||
<span className={styles.sectionSubtitle}>Default 127.0.0.1</span>
|
||||
)}
|
||||
</label>
|
||||
<Input
|
||||
id='targetIP'
|
||||
placeholder='127.0.0.1'
|
||||
width='140px'
|
||||
size='sm'
|
||||
textAlign='right'
|
||||
variant='ontime-filled-on-light'
|
||||
{...register('targetIP', {
|
||||
required: { value: true, message: 'Required field' },
|
||||
pattern: {
|
||||
value: isIPAddress,
|
||||
message: 'Invalid IP address',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl className={styles.splitSection}>
|
||||
<label htmlFor='portOut'>
|
||||
<span className={styles.sectionTitle}>OSC target Port</span>
|
||||
{errors.portOut ? (
|
||||
<span className={styles.error}>{errors.portOut.message}</span>
|
||||
) : (
|
||||
<span className={styles.sectionSubtitle}>Default 9999</span>
|
||||
)}
|
||||
</label>
|
||||
<Input
|
||||
id='portOut'
|
||||
placeholder='9999'
|
||||
width='75px'
|
||||
size='sm'
|
||||
textAlign='right'
|
||||
maxLength={5}
|
||||
variant='ontime-filled-on-light'
|
||||
{...register('portOut', {
|
||||
required: { value: true, message: 'Required field' },
|
||||
max: { value: 65535, message: 'Port in incorrect range (1024 - 65535)' },
|
||||
min: { value: 1024, message: 'Port in incorrect range (1024 - 65535)' },
|
||||
pattern: {
|
||||
value: isOnlyNumbers,
|
||||
message: 'Value should be numeric',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
</FormControl>
|
||||
<OntimeModalFooter
|
||||
formId='oscSettings'
|
||||
handleRevert={resetForm}
|
||||
isDirty={isDirty}
|
||||
isValid={isValid}
|
||||
isSubmitting={isSubmitting}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
import { Control, useFieldArray, UseFormRegister } from 'react-hook-form';
|
||||
import { Button, IconButton, Input, Switch } from '@chakra-ui/react';
|
||||
import { FiChevronUp } from '@react-icons/all-files/fi/FiChevronUp';
|
||||
import { IoRemove } from '@react-icons/all-files/io5/IoRemove';
|
||||
import type { OscSubscription, TimerLifeCycle } from 'ontime-types';
|
||||
|
||||
import { useEmitLog } from '../../../../common/stores/logger';
|
||||
|
||||
import styles from '../../Modal.module.scss';
|
||||
|
||||
interface OscSubscriptionRowProps {
|
||||
cycle: TimerLifeCycle;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
visible: boolean;
|
||||
setShowSection: (cycle: TimerLifeCycle) => void;
|
||||
register: UseFormRegister<OscSubscription>;
|
||||
control: Control<OscSubscription>;
|
||||
placeholder: string;
|
||||
}
|
||||
|
||||
export default function OscSubscriptionRow(props: OscSubscriptionRowProps) {
|
||||
const { cycle, title, subtitle, visible, setShowSection, register, control, placeholder } = props;
|
||||
const { emitError } = useEmitLog();
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
name: cycle,
|
||||
control,
|
||||
});
|
||||
|
||||
const hasTooManyOptions = fields.length >= 3;
|
||||
const headerStyle = `${styles.splitSection} ${visible ? '' : styles.showPointer}`;
|
||||
|
||||
const sectionTitle = `${title} ${fields.length ? fields.length : '-'} / 3`;
|
||||
|
||||
const handleAddNew = () => {
|
||||
if (hasTooManyOptions) {
|
||||
emitError('Maximum amount of onLoad subscriptions reached (3)');
|
||||
return;
|
||||
}
|
||||
append({
|
||||
message: '',
|
||||
enabled: false,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={headerStyle} onClick={() => setShowSection(cycle)}>
|
||||
<div>
|
||||
<span className={`${styles.sectionTitle} ${styles.main}`}>{sectionTitle}</span>
|
||||
{visible && <span className={styles.sectionSubtitle}>{subtitle}</span>}
|
||||
</div>
|
||||
<FiChevronUp />
|
||||
</div>
|
||||
{visible && (
|
||||
<>
|
||||
{fields.map((subscription, index) => (
|
||||
<div key={subscription.id} className={styles.entryRow}>
|
||||
<IconButton
|
||||
icon={<IoRemove />}
|
||||
onClick={() => remove(index)}
|
||||
aria-label='delete'
|
||||
size='xs'
|
||||
colorScheme='red'
|
||||
/>
|
||||
<Input
|
||||
placeholder={placeholder}
|
||||
size='xs'
|
||||
variant='ontime-filled-on-light'
|
||||
autoComplete='off'
|
||||
{...register(`${cycle}.${index}.message`)}
|
||||
/>
|
||||
<Switch variant='ontime-on-light' {...register(`${cycle}.${index}.enabled`)} />
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
onClick={handleAddNew}
|
||||
className={styles.shiftRight}
|
||||
isDisabled={hasTooManyOptions}
|
||||
size='xs'
|
||||
colorScheme='blue'
|
||||
variant='outline'
|
||||
padding='0 2em'
|
||||
>
|
||||
Add new
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -72,8 +72,8 @@ export default function AppSettingsModal() {
|
||||
variant='ontime-filled-on-light'
|
||||
{...register('serverPort', {
|
||||
required: { value: true, message: 'Required field' },
|
||||
max: { value: 65535, message: 'Port in incorrect range (1024 - 65535)' },
|
||||
min: { value: 1024, message: 'Port in incorrect range (1024 - 65535)' },
|
||||
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',
|
||||
|
||||
@@ -18,6 +18,7 @@ export const ontimeAlertOnDark = {
|
||||
borderRadius: '3px',
|
||||
},
|
||||
icon: {
|
||||
alignSelf: 'start',
|
||||
color: '#578AF4', // $blue-500
|
||||
},
|
||||
};
|
||||
|
||||
@@ -58,6 +58,12 @@ export const ontimeButtonGhostedWhite = {
|
||||
export const ontimeButtonGhosted = {
|
||||
...ontimeButtonSubtle,
|
||||
backgroundColor: 'transparent',
|
||||
_hover: {
|
||||
background: '#404040', // $gray-1000
|
||||
_disabled: {
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const ontimeButtonSubtleOnLight = {
|
||||
|
||||
Reference in New Issue
Block a user