Skip to content

Commit

Permalink
wip: automation UI
Browse files Browse the repository at this point in the history
merge with automation service
  • Loading branch information
cpvalente committed Jan 6, 2025
1 parent da50e0c commit 6409c38
Show file tree
Hide file tree
Showing 18 changed files with 1,234 additions and 22 deletions.
85 changes: 85 additions & 0 deletions apps/client/src/common/api/automation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import axios from 'axios';
import type {
Automation,
AutomationBlueprint,
AutomationBlueprintDTO,
AutomationDTO,
AutomationOutput,
AutomationSettings,
} from 'ontime-types';

import { apiEntryUrl } from './constants';

const automationsPath = `${apiEntryUrl}/automations`;

/**
* HTTP request to get the automations settings
*/
export async function getAutomationSettings(): Promise<AutomationSettings> {
const res = await axios.get(automationsPath);
return res.data;
}

/**
* HTTP request to edit the automations settings
*/
export async function editAutomationSettings(
automationSettings: Partial<AutomationSettings>,
): Promise<AutomationSettings> {
const res = await axios.post(automationsPath, automationSettings);
return res.data;
}

/**
* HTTP request to create a new automation
*/
export async function addAutomation(automation: AutomationDTO): Promise<Automation> {
const res = await axios.post(`${automationsPath}/automation`, automation);
return res.data;
}

/**
* HTTP request to update an automation
*/
export async function editAutomation(id: string, automation: Automation): Promise<Automation> {
const res = await axios.put(`${automationsPath}/automation/${id}`, automation);
return res.data;
}

/**
* HTTP request to delete an automation
*/
export function deleteAutomation(id: string): Promise<void> {
return axios.delete(`${automationsPath}/automation/${id}`);
}

/**
* HTTP request to create a new blueprint
*/
export async function addBlueprint(blueprint: AutomationBlueprintDTO): Promise<AutomationBlueprint> {
const res = await axios.post(`${automationsPath}/blueprint`, blueprint);
return res.data;
}

/**
* HTTP request to update a blueprint
*/
export async function editBlueprint(id: string, blueprint: AutomationBlueprint): Promise<AutomationBlueprint> {
const res = await axios.put(`${automationsPath}/blueprint/${id}`, blueprint);
return res.data;
}

/**
* HTTP request to delete a blueprint
*/
export function deleteBlueprint(id: string): Promise<void> {
return axios.delete(`${automationsPath}/blueprint/${id}`);
}

/**
* HTTP request to test automation output
* The return is irrelevant as we care for the resolution of the promise
*/
export async function testOutput(output: AutomationOutput): Promise<void> {
return axios.post(automationsPath, output);
}
1 change: 1 addition & 0 deletions apps/client/src/common/api/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { serverURL } from '../../externals';
export const APP_INFO = ['appinfo'];
export const APP_SETTINGS = ['appSettings'];
export const APP_VERSION = ['appVersion'];
export const AUTOMATION = ['automation'];
export const CUSTOM_FIELDS = ['customFields'];
export const HTTP_SETTINGS = ['httpSettings'];
export const OSC_SETTINGS = ['oscSettings'];
Expand Down
2 changes: 2 additions & 0 deletions apps/client/src/features/app-settings/AppSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { ErrorBoundary } from '@sentry/react';
import { useKeyDown } from '../../common/hooks/useKeyDown';

import AboutPanel from './panel/about-panel/AboutPanel';
import AutomationPanel from './panel/automations-panel/AutomationPanel';
import ClientControlPanel from './panel/client-control-panel/ClientControlPanel';
import FeatureSettingsPanel from './panel/feature-settings-panel/FeatureSettingsPanel';
import GeneralPanel from './panel/general-panel/GeneralPanel';
Expand Down Expand Up @@ -31,6 +32,7 @@ export default function AppSettings() {
{panel === 'feature_settings' && <FeatureSettingsPanel location={location} />}
{panel === 'sources' && <SourcesPanel />}
{panel === 'integrations' && <IntegrationsPanel location={location} />}
{panel === 'automation' && <AutomationPanel location={location} />}
{panel === 'client_control' && <ClientControlPanel />}
{panel === 'about' && <AboutPanel />}
{panel === 'network' && <NetworkLogPanel location={location} />}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,12 @@
flex-direction: column;
height: 100%;
width: 100%;

position: relative;
}

.content {
margin: 1rem;
overflow-y: auto;
flex-grow: 1;
padding-bottom: 300px;
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@ $inner-padding: 1rem;
flex-direction: column;
gap: 1rem;
color: $ui-white;

}

.section {
Expand Down Expand Up @@ -175,8 +174,8 @@ $inner-padding: 1rem;
background-color: $black-10;
text-align: center;
color: $muted-gray;

td {
td {
padding: 2rem;
}

Expand All @@ -191,20 +190,6 @@ $inner-padding: 1rem;
gap: 1rem;
}

.empty {
background-color: $black-10;
text-align: center;
color: $muted-gray;

td {
padding: 2rem;
}

button {
margin-top: 1rem;
}
}

@keyframes animloader {
0% {
transform: scale(0);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,9 @@ export function Card({ children, className, ...props }: { children: ReactNode }
}

export function Table({ className, children }: { className?: string; children: ReactNode }) {
const classes = cx([style.table, className]);
return (
<div className={style.pad}>
<table className={classes}>{children}</table>
<table className={cx([style.table, className])}>{children}</table>
</div>
);
}
Expand All @@ -68,7 +67,7 @@ export function TableEmpty({ handleClick }: { handleClick: () => void }) {
<tr className={style.empty}>
<td colSpan={99}>
<div>No data yet</div>
<Button onClick={handleClick} variant='ontime-subtle' rightIcon={<IoAdd />} size='sm'>
<Button onClick={handleClick} variant='ontime-filled' rightIcon={<IoAdd />} size='sm'>
New
</Button>
</td>
Expand Down Expand Up @@ -124,3 +123,7 @@ export function Loader({ isLoading }: { isLoading: boolean }) {
</div>
);
}

export function InlineSiblings({ children }: { children: ReactNode }) {
return <div className={style.inlineSiblings}>{children}</div>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
.list {
display: flex;
flex-direction: column;
gap: 3rem;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
import type { PanelBaseProps } from '../../panel-list/PanelList';
import * as Panel from '../../panel-utils/PanelUtils';

import AutomationSettingsForm from './AutomationSettingsForm';
import AutomationsList from './AutomationsList';
import BlueprintsList from './BlueprintsList';

export default function AutomationPanel({ location }: PanelBaseProps) {
const { data } = useAutomationSettings();
const settingsRef = useScrollIntoView<HTMLDivElement>('settings', location);
const automationRef = useScrollIntoView<HTMLDivElement>('automations', location);
const blueprintsRef = useScrollIntoView<HTMLDivElement>('blueprints', location);

return (
<>
<Panel.Header>Automation</Panel.Header>
<Panel.Section>
<div ref={settingsRef}>
<AutomationSettingsForm
enabledAutomations={data.enabledAutomations}
enabledOscIn={data.enabledOscIn}
oscPortIn={data.oscPortIn}
/>
</div>
<div ref={automationRef}>
<AutomationsList automations={data.automations} blueprints={data.blueprints} />
</div>
<div ref={blueprintsRef}>
<BlueprintsList blueprints={data.blueprints} />
</div>
</Panel.Section>
</>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { Controller, useForm } from 'react-hook-form';
import { Alert, AlertDescription, AlertIcon, Button, Input, Switch } from '@chakra-ui/react';

import { editAutomationSettings } from '../../../../common/api/automation';
import { maybeAxiosError } from '../../../../common/api/utils';
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
import { preventEscape } from '../../../../common/utils/keyEvent';
import { isOnlyNumbers } from '../../../../common/utils/regex';
import * as Panel from '../../panel-utils/PanelUtils';

const oscApiDocsUrl = 'https://docs.getontime.no/api/protocols/osc/';

interface AutomationSettingsProps {
enabledAutomations: boolean;
enabledOscIn: boolean;
oscPortIn: number;
}

export default function AutomationSettingsForm(props: AutomationSettingsProps) {
const { enabledAutomations, enabledOscIn, oscPortIn } = props;

const {
control,
handleSubmit,
reset,
register,
setError,
formState: { errors, isSubmitting, isDirty, isValid },
} = useForm<AutomationSettingsProps>({
mode: 'onChange',
defaultValues: { enabledAutomations, enabledOscIn, oscPortIn },
values: { enabledAutomations, enabledOscIn, oscPortIn },
resetOptions: {
keepDirtyValues: true,
},
});

const onSubmit = async (formData: AutomationSettingsProps) => {
try {
await editAutomationSettings(formData);
} catch (error) {
const message = maybeAxiosError(error);
setError('root', { message });
}
};

const onReset = () => {
reset({ enabledAutomations, enabledOscIn, oscPortIn });
};

const canSubmit = !isSubmitting && isDirty && isValid;

return (
<Panel.Card>
<Panel.SubHeader>
Automation settings
<Panel.InlineSiblings>
<Button variant='ontime-ghosted' size='sm' onClick={onReset} isDisabled={!canSubmit}>
Revert to saved
</Button>
<Button
variant='ontime-filled'
size='sm'
type='submit'
form='automation-form'
isDisabled={!canSubmit}
isLoading={isSubmitting}
>
Save
</Button>
</Panel.InlineSiblings>
</Panel.SubHeader>
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}

<Panel.Divider />

<Panel.Section>
<Alert status='info' variant='ontime-on-dark-info'>
<AlertIcon />
<AlertDescription>
Control Ontime and share its data with external systems in your workflow. <br />
<br />
- Automations allow Ontime to send its data on lifecycle triggers. <br />- OSC Input tells Ontime to listen
to messages on the specific port. <ExternalLink href={oscApiDocsUrl}>See the docs</ExternalLink>
</AlertDescription>
</Alert>
</Panel.Section>

<Panel.Section as='form' id='automation-form' onSubmit={handleSubmit(onSubmit)} onKeyDown={preventEscape}>
<Panel.Loader isLoading={false} />

<Panel.Title>Automation</Panel.Title>
<Panel.ListGroup>
<Panel.ListItem>
<Panel.Field
title='Enable automations'
description='Allow Ontime to send messages on lifecycle triggers'
error={errors.enabledAutomations?.message}
/>
<Controller
control={control}
name='enabledAutomations'
render={({ field: { onChange, value, ref } }) => (
<Switch variant='ontime' size='lg' isChecked={value} onChange={onChange} ref={ref} />
)}
/>
</Panel.ListItem>
</Panel.ListGroup>

<Panel.Title>OSC Input</Panel.Title>
<Panel.ListGroup>
<Panel.ListItem>
<Panel.Field
title='OSC input'
description='Allow control of Ontime through OSC'
error={errors.enabledOscIn?.message}
/>
<Controller
control={control}
name='enabledOscIn'
render={({ field: { onChange, value, ref } }) => (
<Switch variant='ontime' size='lg' isChecked={value} onChange={onChange} ref={ref} />
)}
/>
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field
title='Listen on port'
description='Port for incoming OSC. Default: 8888'
error={errors.oscPortIn?.message}
/>
<Input
id='oscPortIn'
placeholder='8888'
width='5rem'
maxLength={5}
size='sm'
textAlign='right'
variant='ontime-filled'
type='number'
autoComplete='off'
{...register('oscPortIn', {
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.Section>
</Panel.Card>
);
}
Loading

0 comments on commit 6409c38

Please sign in to comment.