From 8b84963e17c93186ece798cb8a6ba7fe9771e976 Mon Sep 17 00:00:00 2001 From: Carlos Valente Date: Sun, 24 Nov 2024 14:33:54 +0100 Subject: [PATCH] feat: automation UI merge with automation service --- apps/client/src/common/api/automation.ts | 85 ++++ apps/client/src/common/api/constants.ts | 3 +- .../src/features/app-settings/AppSettings.tsx | 2 + .../panel-content/PanelContent.module.scss | 2 +- .../panel-utils/PanelUtils.module.scss | 36 +- .../app-settings/panel-utils/PanelUtils.tsx | 30 +- .../automations-panel/AutomationForm.tsx | 139 ++++++ .../automations-panel/AutomationPanel.tsx | 39 ++ .../AutomationSettingsForm.tsx | 161 +++++++ .../automations-panel/AutomationsList.tsx | 117 +++++ .../automations-panel/AutomationsListItem.tsx | 87 ++++ .../BlueprintForm.module.scss | 82 ++++ .../panel/automations-panel/BlueprintForm.tsx | 446 ++++++++++++++++++ .../automations-panel/BlueprintsList.tsx | 130 +++++ .../__tests__/automationUtils.test.ts | 24 + .../automations-panel/automationUtils.ts | 83 ++++ .../app-settings/useAppSettingsMenu.tsx | 9 + apps/client/src/theme/ontimeRadio.ts | 21 + apps/client/src/theme/theme.ts | 3 +- 19 files changed, 1473 insertions(+), 26 deletions(-) create mode 100644 apps/client/src/common/api/automation.ts create mode 100644 apps/client/src/features/app-settings/panel/automations-panel/AutomationForm.tsx create mode 100644 apps/client/src/features/app-settings/panel/automations-panel/AutomationPanel.tsx create mode 100644 apps/client/src/features/app-settings/panel/automations-panel/AutomationSettingsForm.tsx create mode 100644 apps/client/src/features/app-settings/panel/automations-panel/AutomationsList.tsx create mode 100644 apps/client/src/features/app-settings/panel/automations-panel/AutomationsListItem.tsx create mode 100644 apps/client/src/features/app-settings/panel/automations-panel/BlueprintForm.module.scss create mode 100644 apps/client/src/features/app-settings/panel/automations-panel/BlueprintForm.tsx create mode 100644 apps/client/src/features/app-settings/panel/automations-panel/BlueprintsList.tsx create mode 100644 apps/client/src/features/app-settings/panel/automations-panel/__tests__/automationUtils.test.ts create mode 100644 apps/client/src/features/app-settings/panel/automations-panel/automationUtils.ts diff --git a/apps/client/src/common/api/automation.ts b/apps/client/src/common/api/automation.ts new file mode 100644 index 000000000..d2384da8f --- /dev/null +++ b/apps/client/src/common/api/automation.ts @@ -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 { + const res = await axios.get(automationsPath); + return res.data; +} + +/** + * HTTP request to edit the automations settings + */ +export async function editAutomationSettings( + automationSettings: Partial, +): Promise { + const res = await axios.post(automationsPath, automationSettings); + return res.data; +} + +/** + * HTTP request to create a new automation + */ +export async function addAutomation(automation: AutomationDTO): Promise { + 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 { + const res = await axios.put(`${automationsPath}/automation/${id}`, automation); + return res.data; +} + +/** + * HTTP request to delete an automation + */ +export function deleteAutomation(id: string): Promise { + return axios.delete(`${automationsPath}/automation/${id}`); +} + +/** + * HTTP request to create a new blueprint + */ +export async function addBlueprint(blueprint: AutomationBlueprintDTO): Promise { + 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 { + const res = await axios.put(`${automationsPath}/blueprint/${id}`, blueprint); + return res.data; +} + +/** + * HTTP request to delete a blueprint + */ +export function deleteBlueprint(id: string): Promise { + 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 { + return axios.post(automationsPath, output); +} diff --git a/apps/client/src/common/api/constants.ts b/apps/client/src/common/api/constants.ts index 789e1ce87..98629d28b 100644 --- a/apps/client/src/common/api/constants.ts +++ b/apps/client/src/common/api/constants.ts @@ -4,9 +4,8 @@ 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']; export const PROJECT_DATA = ['project']; export const PROJECT_LIST = ['projectList']; export const RUNDOWN = ['rundown']; diff --git a/apps/client/src/features/app-settings/AppSettings.tsx b/apps/client/src/features/app-settings/AppSettings.tsx index 28c0098f1..a4ceb8cc6 100644 --- a/apps/client/src/features/app-settings/AppSettings.tsx +++ b/apps/client/src/features/app-settings/AppSettings.tsx @@ -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'; @@ -31,6 +32,7 @@ export default function AppSettings() { {panel === 'feature_settings' && } {panel === 'sources' && } {panel === 'integrations' && } + {panel === 'automation' && } {panel === 'client_control' && } {panel === 'about' && } {panel === 'network' && } diff --git a/apps/client/src/features/app-settings/panel-content/PanelContent.module.scss b/apps/client/src/features/app-settings/panel-content/PanelContent.module.scss index bcb0cc997..d9fc0a1a3 100644 --- a/apps/client/src/features/app-settings/panel-content/PanelContent.module.scss +++ b/apps/client/src/features/app-settings/panel-content/PanelContent.module.scss @@ -10,7 +10,6 @@ flex-direction: column; height: 100%; width: 100%; - position: relative; } @@ -18,4 +17,5 @@ margin: 1rem; overflow-y: auto; flex-grow: 1; + padding-bottom: 300px; } diff --git a/apps/client/src/features/app-settings/panel-utils/PanelUtils.module.scss b/apps/client/src/features/app-settings/panel-utils/PanelUtils.module.scss index e2a8917d4..51ab46c73 100644 --- a/apps/client/src/features/app-settings/panel-utils/PanelUtils.module.scss +++ b/apps/client/src/features/app-settings/panel-utils/PanelUtils.module.scss @@ -26,13 +26,13 @@ $inner-padding: 1rem; color: $gray-300; } -.section, .indent { +.section, +.indent { font-size: calc(1rem - 1px); display: flex; flex-direction: column; gap: 1rem; color: $ui-white; - } .section { @@ -111,6 +111,12 @@ $inner-padding: 1rem; tr:nth-child(even) { background-color: $white-1; } + + // allow highlighting table elements + tr[data-warn='true'], + td[data-warn='true'] { + background-color: $orange-1300; + } } .listGroup { @@ -177,7 +183,7 @@ $inner-padding: 1rem; color: $muted-gray; td { - padding: 2rem; + padding-block: 1rem; } button { @@ -185,23 +191,21 @@ $inner-padding: 1rem; } } -.inlineSiblings { +.inlineElements { display: flex; align-items: center; - gap: 1rem; -} -.empty { - background-color: $black-10; - text-align: center; - color: $muted-gray; - - td { - padding: 2rem; + &.inner { + gap: 0.5rem; } - - button { - margin-top: 1rem; + &.component { + gap: 1rem; + } + &.start { + justify-content: flex-start; + } + &.end { + justify-content: flex-end; } } diff --git a/apps/client/src/features/app-settings/panel-utils/PanelUtils.tsx b/apps/client/src/features/app-settings/panel-utils/PanelUtils.tsx index 541378fdb..f8b081169 100644 --- a/apps/client/src/features/app-settings/panel-utils/PanelUtils.tsx +++ b/apps/client/src/features/app-settings/panel-utils/PanelUtils.tsx @@ -1,4 +1,4 @@ -import { HTMLAttributes, ReactNode } from 'react'; +import { HTMLAttributes, PropsWithChildren, ReactNode } from 'react'; import { Button } from '@chakra-ui/react'; import { IoAdd } from '@react-icons/all-files/io5/IoAdd'; @@ -55,20 +55,19 @@ export function Card({ children, className, ...props }: { children: ReactNode } } export function Table({ className, children }: { className?: string; children: ReactNode }) { - const classes = cx([style.table, className]); return (
- {children}
+ {children}
); } -export function TableEmpty({ handleClick }: { handleClick: () => void }) { +export function TableEmpty({ label, handleClick }: { label?: string; handleClick?: () => void }) { return ( -
No data yet
- @@ -124,3 +123,22 @@ export function Loader({ isLoading }: { isLoading: boolean }) { ); } + +type AllowedInlineTags = 'div' | 'td'; +type InlineProps = { + as?: C; + relation?: 'inner' | 'component' | 'section'; + align?: 'start' | 'end'; + className?: string; +}; + +export function InlineElements({ + children, + as, + relation = 'component', + align = 'start', + className, +}: PropsWithChildren>) { + const Element = as ?? 'div'; + return {children}; +} diff --git a/apps/client/src/features/app-settings/panel/automations-panel/AutomationForm.tsx b/apps/client/src/features/app-settings/panel/automations-panel/AutomationForm.tsx new file mode 100644 index 000000000..39bb02eaa --- /dev/null +++ b/apps/client/src/features/app-settings/panel/automations-panel/AutomationForm.tsx @@ -0,0 +1,139 @@ +import { useEffect } from 'react'; +import { useForm } from 'react-hook-form'; +import { Button, Input, Select } from '@chakra-ui/react'; +import { AutomationDTO, NormalisedAutomationBlueprint, TimerLifeCycle } from 'ontime-types'; + +import { addAutomation, editAutomation } from '../../../../common/api/automation'; +import { maybeAxiosError } from '../../../../common/api/utils'; +import { preventEscape } from '../../../../common/utils/keyEvent'; +import * as Panel from '../../panel-utils/PanelUtils'; + +import { cycles } from './automationUtils'; + +interface AutomationFormProps { + blueprints: NormalisedAutomationBlueprint; + initialId?: string; + initialTitle?: string; + initialBlueprint?: string; + initialTrigger?: TimerLifeCycle; + onCancel: () => void; + postSubmit: () => void; +} + +export default function AutomationForm(props: AutomationFormProps) { + const { blueprints, initialId, initialTitle, initialBlueprint, initialTrigger, onCancel, postSubmit } = props; + const { + handleSubmit, + register, + setFocus, + setError, + formState: { errors, isSubmitting, isValid, isDirty }, + } = useForm({ + defaultValues: { + title: initialTitle, + trigger: initialTrigger, + blueprintId: initialBlueprint, + }, + resetOptions: { + keepDirtyValues: true, + }, + }); + + // give initial focus to the title field + useEffect(() => { + setFocus('title'); + // eslint-disable-next-line react-hooks/exhaustive-deps -- focus on mount + }, []); + + const onSubmit = async (values: AutomationDTO) => { + // if we were passed an ID we are editing a blueprint + if (initialId) { + try { + await editAutomation(initialId, { id: initialId, ...values }); + postSubmit(); + } catch (error) { + setError('root', { message: `Failed to save changes to automation ${maybeAxiosError(error)}` }); + } + return; + } + + // otherwise we are creating a new automation + try { + await addAutomation(values); + postSubmit(); + } catch (error) { + setError('root', { message: `Failed to save automation ${maybeAxiosError(error)}` }); + } + }; + + const blueprintSelect = Object.keys(blueprints).map((blueprint) => { + return { + value: blueprint, + label: blueprints[blueprint].title, + }; + }); + + const canSubmit = isDirty && isValid; + + return ( + preventEscape(event, onCancel)} + > + {initialId ? 'Edit automation' : 'Create automation'} + + + + + + + + + ); +} diff --git a/apps/client/src/features/app-settings/panel/automations-panel/AutomationPanel.tsx b/apps/client/src/features/app-settings/panel/automations-panel/AutomationPanel.tsx new file mode 100644 index 000000000..64df2a6b7 --- /dev/null +++ b/apps/client/src/features/app-settings/panel/automations-panel/AutomationPanel.tsx @@ -0,0 +1,39 @@ +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, status } = useAutomationSettings(); + const settingsRef = useScrollIntoView('settings', location); + const automationRef = useScrollIntoView('automations', location); + const blueprintsRef = useScrollIntoView('blueprints', location); + + const isLoading = status === 'pending'; + + return ( + <> + Automation + + +
+ +
+
+ +
+
+ +
+
+ + ); +} diff --git a/apps/client/src/features/app-settings/panel/automations-panel/AutomationSettingsForm.tsx b/apps/client/src/features/app-settings/panel/automations-panel/AutomationSettingsForm.tsx new file mode 100644 index 000000000..ce0465704 --- /dev/null +++ b/apps/client/src/features/app-settings/panel/automations-panel/AutomationSettingsForm.tsx @@ -0,0 +1,161 @@ +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({ + 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 ( + + + Automation settings + + + + + + {errors?.root && {errors.root.message}} + + + + + + + + Control Ontime and share its data with external systems in your workflow.
+ - Automations allow Ontime to send its data on lifecycle triggers.
- OSC Input tells Ontime to listen + to messages on the specific port. See the docs +
+
+
+ + preventEscape(event, onReset)} + > + + + Automation + + + + ( + + )} + /> + + + + OSC Input + + + + ( + + )} + /> + + + + + + + +
+ ); +} diff --git a/apps/client/src/features/app-settings/panel/automations-panel/AutomationsList.tsx b/apps/client/src/features/app-settings/panel/automations-panel/AutomationsList.tsx new file mode 100644 index 000000000..e6ccdf02e --- /dev/null +++ b/apps/client/src/features/app-settings/panel/automations-panel/AutomationsList.tsx @@ -0,0 +1,117 @@ +import { Fragment, useMemo, useState } from 'react'; +import { Button } from '@chakra-ui/react'; +import { IoAdd } from '@react-icons/all-files/io5/IoAdd'; +import { Automation, NormalisedAutomationBlueprint } from 'ontime-types'; + +import { deleteAutomation } from '../../../../common/api/automation'; +import { maybeAxiosError } from '../../../../common/api/utils'; +import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings'; +import * as Panel from '../../panel-utils/PanelUtils'; + +import AutomationForm from './AutomationForm'; +import AutomationsListItem from './AutomationsListItem'; +import { checkDuplicates } from './automationUtils'; + +interface AutomationsListProps { + automations: Automation[]; + blueprints: NormalisedAutomationBlueprint; +} + +export default function AutomationsList(props: AutomationsListProps) { + const { automations, blueprints } = props; + const [showForm, setShowForm] = useState(false); + const { refetch } = useAutomationSettings(); + const [deleteError, setDeleteError] = useState(null); + + const handleDelete = async (id: string) => { + try { + await deleteAutomation(id); + } catch (error) { + setDeleteError(maybeAxiosError(error)); + } finally { + refetch(); + } + }; + + const postSubmit = () => { + setShowForm(false); + refetch(); + }; + + const duplicates = useMemo(() => checkDuplicates(automations), [automations]); + + // there is no point letting user creating an automation if there are no blueprints + const canAdd = Object.keys(blueprints).length > 0; + + return ( + + + Manage automations + + + + + {duplicates && ( + + You have created multiple links between the same trigger and blueprint which can performance issues. + + )} + {showForm && ( + setShowForm(false)} postSubmit={postSubmit} /> + )} + + + + Title + Trigger + Blueprint + + + + + {!showForm && automations.length === 0 && ( + setShowForm(true) : undefined} + /> + )} + {automations.map((automation, index) => { + return ( + + handleDelete(automation.id)} + postSubmit={postSubmit} + /> + {deleteError && ( + + + {deleteError} + + + )} + + ); + })} + + + + + ); +} diff --git a/apps/client/src/features/app-settings/panel/automations-panel/AutomationsListItem.tsx b/apps/client/src/features/app-settings/panel/automations-panel/AutomationsListItem.tsx new file mode 100644 index 000000000..3d0051ee5 --- /dev/null +++ b/apps/client/src/features/app-settings/panel/automations-panel/AutomationsListItem.tsx @@ -0,0 +1,87 @@ +import { useState } from 'react'; +import { IconButton } from '@chakra-ui/react'; +import { IoPencil } from '@react-icons/all-files/io5/IoPencil'; +import { IoTrash } from '@react-icons/all-files/io5/IoTrash'; +import { IoWarningOutline } from '@react-icons/all-files/io5/IoWarningOutline'; +import { NormalisedAutomationBlueprint, TimerLifeCycle } from 'ontime-types'; + +import Tag from '../../../../common/components/tag/Tag'; +import * as Panel from '../../panel-utils/PanelUtils'; + +import AutomationForm from './AutomationForm'; +import { cycles } from './automationUtils'; + +interface AutomationsListItemProps { + blueprints: NormalisedAutomationBlueprint; + id: string; + title: string; + trigger: TimerLifeCycle; + blueprintId: string; + duplicate?: boolean; + handleDelete: () => void; + postSubmit: () => void; +} + +export default function AutomationsListItem(props: AutomationsListItemProps) { + const { blueprints, id, title, trigger, blueprintId, duplicate, handleDelete, postSubmit } = props; + const [isEditing, setIsEditing] = useState(false); + + if (isEditing) { + return ( + + + setIsEditing(false)} + postSubmit={() => { + setIsEditing(false); + postSubmit(); + }} + /> + + + ); + } + + const blueprintTitle = blueprints?.[blueprintId]?.title; + return ( + + + {duplicate && ( + + )} + {title} + + + {cycles.find((cycle) => cycle.value === trigger)?.label} + + + {blueprintTitle} + + + } + aria-label='Edit entry' + onClick={() => setIsEditing(true)} + /> + } + aria-label='Delete entry' + onClick={handleDelete} + /> + + + ); +} diff --git a/apps/client/src/features/app-settings/panel/automations-panel/BlueprintForm.module.scss b/apps/client/src/features/app-settings/panel/automations-panel/BlueprintForm.module.scss new file mode 100644 index 000000000..b2e0dba86 --- /dev/null +++ b/apps/client/src/features/app-settings/panel/automations-panel/BlueprintForm.module.scss @@ -0,0 +1,82 @@ +.outerColumn { + gap: 2rem; + margin-bottom: 2rem; + + h3 { + font-size: 1rem; + } +} + +.innerColumn { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.matchRadio { + display: flex; + gap: 1rem; +} + +.ruleSection { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.titleSection, +.filterSection, +.oscSection, +.httpSection, +.companionSection { + display: grid; + grid-gap: 0.5rem; + + button { + align-self: end; + } +} + +.titleSection, +.ruleSection, +.filterSection, +.oscSection, +.httpSection, +.companionSection { + label { + font-size: calc(1rem - 3px); + color: $label-gray; + } +} + + +.titleSection { + grid-template-columns: 1fr; +} + +.filterSection { + grid-template-columns: 2fr 1fr 2fr auto; +} + +.oscSection { + grid-template-columns: 9rem 5rem 3fr 4fr auto; +} + +.httpSection { + grid-template-columns: 1fr auto; +} + +.actions { + justify-content: flex-end; +} + +.actionButtons { + display: flex; + gap: 1rem; + margin-top: 1rem; +} + +.outputCard { + border-left: 0.25rem solid $gray-1200; + padding-left: 0.5rem; +} diff --git a/apps/client/src/features/app-settings/panel/automations-panel/BlueprintForm.tsx b/apps/client/src/features/app-settings/panel/automations-panel/BlueprintForm.tsx new file mode 100644 index 000000000..f6cd2d769 --- /dev/null +++ b/apps/client/src/features/app-settings/panel/automations-panel/BlueprintForm.tsx @@ -0,0 +1,446 @@ +import { useEffect, useMemo } from 'react'; +import { Controller, useFieldArray, useForm } from 'react-hook-form'; +import { Button, IconButton, Input, Radio, RadioGroup, Select } from '@chakra-ui/react'; +import { IoAdd } from '@react-icons/all-files/io5/IoAdd'; +import { IoTrash } from '@react-icons/all-files/io5/IoTrash'; +import { + AutomationBlueprint, + AutomationBlueprintDTO, + HTTPOutput, + isHTTPOutput, + isOSCOutput, + OSCOutput, +} from 'ontime-types'; + +import { addBlueprint, editBlueprint } from '../../../../common/api/automation'; +import { maybeAxiosError } from '../../../../common/api/utils'; +import Tag from '../../../../common/components/tag/Tag'; +import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings'; +import useCustomFields from '../../../../common/hooks-query/useCustomFields'; +import { preventEscape } from '../../../../common/utils/keyEvent'; +import { startsWithHttp } from '../../../../common/utils/regex'; +import * as Panel from '../../panel-utils/PanelUtils'; + +import { isBlueprint, makeFieldList } from './automationUtils'; + +import style from './BlueprintForm.module.scss'; + +interface BlueprintFormProps { + blueprint: AutomationBlueprintDTO | AutomationBlueprint; + onClose: () => void; +} + +export default function BlueprintForm(props: BlueprintFormProps) { + const { blueprint, onClose } = props; + const isEdit = isBlueprint(blueprint); + const { data } = useCustomFields(); + const { refetch } = useAutomationSettings(); + const fieldList = useMemo(() => makeFieldList(data), [data]); + + const { + control, + handleSubmit, + register, + setError, + setFocus, + formState: { errors, isSubmitting, isDirty, isValid }, + } = useForm({ + mode: 'onChange', + defaultValues: { + title: blueprint?.title ?? '', + filterRule: blueprint?.filterRule ?? 'all', + filters: blueprint?.filters ?? [], + outputs: blueprint?.outputs ?? [], + }, + resetOptions: { + keepDirtyValues: true, + }, + }); + + const { + fields: fieldFilters, + append: appendFilter, + remove: removeFilter, + } = useFieldArray({ + name: 'filters', + control, + }); + + const { + fields: fieldOutputs, + append: appendOutput, + remove: removeOutput, + } = useFieldArray({ + name: 'outputs', + control, + }); + + // give initial focus to the title field + useEffect(() => { + setFocus('title'); + }, [setFocus]); + + const handleAddNewFilter = () => { + appendFilter({ field: '', operator: 'equals', value: '' }); + }; + + const handleAddNewOSCOutput = () => { + // @ts-expect-error -- we dont want to pass a port to the new object + appendOutput({ type: 'osc', targetIP: '', targetPort: undefined, address: '', args: '' }); + }; + + const handleAddNewHTTPOutput = () => { + appendOutput({ type: 'http', url: '' }); + }; + + const handleTestOSCOutput = async (index: number) => { + try { + const values = getValues(`outputs.${index}`) as OSCOutput; + if (!values.targetIP || !values.targetPort || !values.address) { + return; + } + await testOutput({ + type: 'osc', + targetIP: values.targetIP, + targetPort: values.targetPort, + address: values.address, + args: values.args, + }); + } catch (_error) { + /** we dont handle errors here, users should use the network tab */ + } + }; + + const handleTestHTTPOutput = async (index: number) => { + try { + const values = getValues(`outputs.${index}`) as HTTPOutput; + if (!values.url) { + return; + } + await testOutput({ + type: 'http', + url: values.url, + }); + } catch (_error) { + /** we dont handle errors here, users should use the network tab */ + } + }; + + const onSubmit = async (values: AutomationBlueprintDTO) => { + if (isBlueprint(blueprint)) { + await handleEdit(blueprint.id, { id: blueprint.id, ...values }); + } else { + await handleCreate(values); + } + refetch(); + + async function handleEdit(id: string, values: AutomationBlueprint) { + try { + await editBlueprint(id, values); + onClose(); + } catch (error) { + setError('root', { message: maybeAxiosError(error) }); + } + } + + async function handleCreate(values: AutomationBlueprintDTO) { + try { + await addBlueprint(values); + onClose(); + } catch (error) { + setError('root', { message: maybeAxiosError(error) }); + } + } + }; + + const canSubmit = !isSubmitting && isDirty && isValid; + + return ( + preventEscape(event, onClose)} + > + {isEdit ? 'Edit blueprint' : 'Create blueprint'} +
+

Blueprint options

+
+ + {errors.title?.message} +
+
+ +
+

Filters

+
+ + {fieldFilters.map((field, index) => ( +
+ + + + } + variant='ontime-ghosted' + size='sm' + color='#FA5656' // $red-500 + onClick={() => removeFilter(index)} + isDisabled={false} + isLoading={false} + /> +
+ ))} +
+ +
+
+
+ +
+

Outputs

+ {fieldOutputs.map((output, index) => { + if (isOSCOutput(output)) { + const rowErrors = errors.outputs?.[index] as + | { + targetIP?: { message?: string }; + targetPort?: { message?: string }; + address?: { message?: string }; + args?: { message?: string }; + } + | undefined; + + return ( +
+ OSC +
+ + + + + + + } + variant='ontime-ghosted' + size='sm' + onClick={() => removeOutput(index)} + color='#FA5656' // $red-500 + isDisabled={false} + isLoading={false} + /> + +
+
+ ); + } + if (isHTTPOutput(output)) { + const rowErrors = errors.outputs?.[index] as + | { + url?: { message?: string }; + } + | undefined; + const canTest = output.url; + return ( +
+ HTTP +
+ + + + } + variant='ontime-ghosted' + size='sm' + onClick={() => removeOutput(index)} + color='#FA5656' // $red-500 + isDisabled={false} + isLoading={false} + /> + +
+
+ ); + } + // there should be no other output types + return null; + })} + + + + +
+ + + {errors?.root && {errors.root.message}} + + + +
+ ); +} diff --git a/apps/client/src/features/app-settings/panel/automations-panel/BlueprintsList.tsx b/apps/client/src/features/app-settings/panel/automations-panel/BlueprintsList.tsx new file mode 100644 index 000000000..a5e111511 --- /dev/null +++ b/apps/client/src/features/app-settings/panel/automations-panel/BlueprintsList.tsx @@ -0,0 +1,130 @@ +import { Fragment, useState } from 'react'; +import { Button, IconButton } from '@chakra-ui/react'; +import { IoAdd } from '@react-icons/all-files/io5/IoAdd'; +import { IoPencil } from '@react-icons/all-files/io5/IoPencil'; +import { IoTrash } from '@react-icons/all-files/io5/IoTrash'; +import { AutomationBlueprintDTO, NormalisedAutomationBlueprint } from 'ontime-types'; + +import { deleteBlueprint } from '../../../../common/api/automation'; +import { maybeAxiosError } from '../../../../common/api/utils'; +import Tag from '../../../../common/components/tag/Tag'; +import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings'; +import * as Panel from '../../panel-utils/PanelUtils'; + +import BlueprintForm from './BlueprintForm'; + +const automationBlueprintPlaceholder: AutomationBlueprintDTO = { + title: '', + filterRule: 'all', + filters: [], + outputs: [], +}; + +interface BlueprintsListProps { + blueprints: NormalisedAutomationBlueprint; +} + +export default function BlueprintsList(props: BlueprintsListProps) { + const { blueprints } = props; + const { refetch } = useAutomationSettings(); + const [blueprintFormData, setBlueprintFormData] = useState( + null, + ); + const [deleteError, setDeleteError] = useState(null); + + const handleDelete = async (id: string) => { + try { + setDeleteError(null); + await deleteBlueprint(id); + } catch (error) { + setDeleteError(maybeAxiosError(error)); + } finally { + refetch(); + } + }; + + const arrayBlueprints = Object.keys(blueprints); + + return ( + + + Manage blueprints + + + + + + {blueprintFormData !== null && ( + setBlueprintFormData(null)} /> + )} + + + + + Title + Trigger rule + Filters + Outputs + + + + + {arrayBlueprints.length === 0 && ( + setBlueprintFormData(automationBlueprintPlaceholder)} /> + )} + {arrayBlueprints.map((blueprintId) => { + if (!Object.hasOwn(blueprints, blueprintId)) { + return null; + } + return ( + + + {blueprints[blueprintId].title} + + {blueprints[blueprintId].filterRule} + + {blueprints[blueprintId].filters.length} + {blueprints[blueprintId].outputs.length} + + } + aria-label='Edit entry' + onClick={() => setBlueprintFormData(blueprints[blueprintId])} + /> + } + aria-label='Delete entry' + onClick={() => handleDelete(blueprintId)} + /> + + + {deleteError && ( + + + {deleteError} + + + )} + + ); + })} + + + + ); +} diff --git a/apps/client/src/features/app-settings/panel/automations-panel/__tests__/automationUtils.test.ts b/apps/client/src/features/app-settings/panel/automations-panel/__tests__/automationUtils.test.ts new file mode 100644 index 000000000..947ef338b --- /dev/null +++ b/apps/client/src/features/app-settings/panel/automations-panel/__tests__/automationUtils.test.ts @@ -0,0 +1,24 @@ +import { Automation, TimerLifeCycle } from 'ontime-types'; + +import { checkDuplicates } from '../automationUtils'; + +describe('checkDuplicates', () => { + it('should return undefined if there are no duplicates', () => { + const automations: Automation[] = [ + { id: '1', title: 'First', trigger: TimerLifeCycle.onClock, blueprintId: '1' }, + { id: '2', title: 'Second', trigger: TimerLifeCycle.onDanger, blueprintId: '2' }, + { id: '3', title: 'Third', trigger: TimerLifeCycle.onLoad, blueprintId: '3' }, + ]; + expect(checkDuplicates(automations)).toBeUndefined(); + }); + + it('should return list of titles of duplicates', () => { + const automations: Automation[] = [ + { id: '1', title: 'First', trigger: TimerLifeCycle.onClock, blueprintId: '1' }, + { id: '2', title: 'Second', trigger: TimerLifeCycle.onDanger, blueprintId: '2' }, + { id: '3', title: 'Third', trigger: TimerLifeCycle.onClock, blueprintId: '1' }, + { id: '3', title: 'Third', trigger: TimerLifeCycle.onPause, blueprintId: '1' }, + ]; + expect(checkDuplicates(automations)).toStrictEqual([2]); + }); +}); diff --git a/apps/client/src/features/app-settings/panel/automations-panel/automationUtils.ts b/apps/client/src/features/app-settings/panel/automations-panel/automationUtils.ts new file mode 100644 index 000000000..a002b606a --- /dev/null +++ b/apps/client/src/features/app-settings/panel/automations-panel/automationUtils.ts @@ -0,0 +1,83 @@ +import { + Automation, + AutomationBlueprint, + AutomationBlueprintDTO, + CustomFields, + OntimeEvent, + TimerLifeCycle, +} from 'ontime-types'; + +type CycleLabel = { + id: number; + label: string; + value: keyof typeof TimerLifeCycle; +}; + +export const cycles: CycleLabel[] = [ + { 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: 'onClock' }, + { id: 6, label: 'On Timer Update', value: 'onUpdate' }, + { id: 7, label: 'On Finish', value: 'onFinish' }, + { id: 8, label: 'On Warning', value: 'onWarning' }, + { id: 9, label: 'On Danger', value: 'onDanger' }, +]; + +/** + * We use this guard to find out if the form is receiving an existing blueprint or creating a DTO + * We do this by checking whether an ID has been generated + */ +export function isBlueprint(blueprint: AutomationBlueprintDTO | AutomationBlueprint): blueprint is AutomationBlueprint { + return Object.hasOwn(blueprint, 'id'); +} + +export const staticSelectProperties = [ + { value: 'id', label: 'ID' }, + { value: 'title', label: 'Title' }, + { value: 'cue', label: 'Cue' }, + { value: 'countToEnd', label: 'Count to end' }, + { value: 'isPublic', label: 'Is public' }, + { value: 'skip', label: 'Skip' }, + { value: 'note', label: 'Note' }, + { value: 'colour', label: 'Colour' }, + { value: 'endAction', label: 'End action' }, + { value: 'timerType', label: 'Timer type' }, + { value: 'timeWarning', label: 'Time warning' }, + { value: 'timeDanger', label: 'Time danger' }, +]; + +type SelectableField = { + value: keyof OntimeEvent | string; // string for custom fields + label: string; +}; + +export function makeFieldList(customFields: CustomFields): SelectableField[] { + return [ + ...staticSelectProperties, + ...Object.entries(customFields).map(([key, { label }]) => ({ value: key, label: `Custom: ${label}` })), + ]; +} + +/** + * We warn the user if they have created multiple links between the same blueprint and automation + */ +export function checkDuplicates(automations: Automation[]) { + const automationMap: Record = {}; + const duplicates = []; + + for (let i = 0; i < automations.length; i++) { + const automation = automations[i]; + if (!Object.hasOwn(automationMap, automation.trigger)) { + automationMap[automation.trigger] = []; + } + + if (automationMap[automation.trigger].includes(automation.blueprintId)) { + duplicates.push(i); + } else { + automationMap[automation.trigger].push(automation.blueprintId); + } + } + return duplicates.length > 0 ? duplicates : undefined; +} diff --git a/apps/client/src/features/app-settings/useAppSettingsMenu.tsx b/apps/client/src/features/app-settings/useAppSettingsMenu.tsx index 7f87b4d2d..5faebdbae 100644 --- a/apps/client/src/features/app-settings/useAppSettingsMenu.tsx +++ b/apps/client/src/features/app-settings/useAppSettingsMenu.tsx @@ -54,6 +54,15 @@ const staticOptions = [ { id: 'integrations__http', label: 'HTTP settings' }, ], }, + { + id: 'automation', + label: 'Automation', + secondary: [ + { id: 'automation__settings', label: 'Automation settings' }, + { id: 'automation__automations', label: 'Manage automations' }, + { id: 'automation__blueprints', label: 'Manage blueprints' }, + ], + }, { id: 'network', label: 'Network', diff --git a/apps/client/src/theme/ontimeRadio.ts b/apps/client/src/theme/ontimeRadio.ts index bad8ba9c6..614084cec 100644 --- a/apps/client/src/theme/ontimeRadio.ts +++ b/apps/client/src/theme/ontimeRadio.ts @@ -1,3 +1,24 @@ +export const ontimeRadio = { + control: { + borderColor: '#262626', // $gray-1200 + backgroundColor: '#262626', // $gray-1200 + _checked: { + borderColor: '#262626', // $gray-1200 + color: '#f6f6f6', // $ui-white + backgroundColor: '#f6f6f6', // $ui-white + }, + }, + label: { + color: '#9d9d9d', // $gray-500, same as placeholder value + _checked: { + color: '#f6f6f6', // $gray-200 + }, + _hover: { + color: '#e2e2e2', // $gray-200 + }, + }, +}; + export const ontimeBlockRadio = { control: { borderColor: '#262626', // $gray-1200 diff --git a/apps/client/src/theme/theme.ts b/apps/client/src/theme/theme.ts index ff68d5177..3003fb001 100644 --- a/apps/client/src/theme/theme.ts +++ b/apps/client/src/theme/theme.ts @@ -14,7 +14,7 @@ import { ontimeDrawer } from './ontimeDrawer'; import { ontimeEditable } from './ontimeEditable'; import { ontimeMenuOnDark } from './ontimeMenu'; import { ontimeModal } from './ontimeModal'; -import { ontimeBlockRadio } from './ontimeRadio'; +import { ontimeBlockRadio, ontimeRadio } from './ontimeRadio'; import { ontimeSelect } from './ontimeSelect'; import { ontimeSwitch } from './ontimeSwitch'; import { ontimeTab } from './ontimeTab'; @@ -109,6 +109,7 @@ const theme = extendTheme({ }, Radio: { variants: { + ontime: { ...ontimeRadio }, 'ontime-block': { ...ontimeBlockRadio }, }, },