diff --git a/apps/client/src/features/app-settings/panel/automations-panel/NewAutomationDialog.module.scss b/apps/client/src/features/app-settings/panel/automations-panel/NewAutomationDialog.module.scss new file mode 100644 index 000000000..83e08dd37 --- /dev/null +++ b/apps/client/src/features/app-settings/panel/automations-panel/NewAutomationDialog.module.scss @@ -0,0 +1,182 @@ +.picker { + display: flex; + flex-direction: column; + gap: 0.25rem; + color: $ui-white; +} + +.search { + position: relative; + display: flex; + align-items: center; + padding-block: 0.5rem; +} + +.listViewport { + height: auto; + max-height: min(52vh, 30rem); +} + +.list { + display: flex; + flex-direction: column; + padding-right: 0.5rem; +} + +.searchIcon { + position: absolute; + left: 0.625rem; + color: $gray-400; + pointer-events: none; +} + +.searchInput { + padding-left: 2rem; + padding-right: 2rem; +} + +.searchClear { + position: absolute; + right: 0.25rem; +} + +.group { + display: flex; + flex-direction: column; + gap: 0.25rem; + padding-top: 0.75rem; + + &:first-child { + padding-top: 0; + } +} + +.groupTitle { + margin: 0; + padding-inline: 0.125rem; + font-size: $aux-text-size; + font-weight: 600; + letter-spacing: 0.02em; + text-transform: uppercase; + color: $gray-400; +} + +.recipe { + display: flex; + align-items: center; + gap: 0.75rem; + width: 100%; + padding: 0.625rem 0.75rem; + text-align: left; + color: inherit; + background-color: transparent; + border: 1px solid transparent; + border-radius: $component-border-radius-md; + cursor: pointer; + + &:hover { + background-color: $white-3; + border-color: $white-10; + } + + &:focus-visible { + outline: 1px solid $action-blue; + outline-offset: -1px; + } +} + +.recipeText { + display: flex; + flex-direction: column; + gap: 0.125rem; + flex: 1; + min-width: 0; +} + +.recipeTitle { + font-weight: 600; +} + +.recipeDescription { + font-size: $aux-text-size; + color: $secondary-text-gray; +} + +.recipeTags { + display: flex; + align-items: center; + gap: 0.5rem; + flex-shrink: 0; +} + +.chevron { + color: $gray-400; +} + +.setup { + display: flex; + flex-direction: column; + gap: 1rem; + color: $ui-white; + padding-block: 0.25rem; +} + +.setupDescription { + margin: 0; + color: $secondary-text-gray; +} + +.summary { + display: grid; + grid-template-columns: 5rem 1fr; + align-items: center; + gap: 0.5rem 0.75rem; + margin: 0; + padding: 0.75rem; + background-color: $black-10; + border: 1px solid $white-10; + border-radius: $component-border-radius-md; + + dt { + font-size: $aux-text-size; + color: $label-gray; + } + + dd { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin: 0; + } +} + +.fields { + display: grid; + grid-template-columns: repeat(3, 1fr); + align-items: start; + gap: 0.75rem; + + @media (width < 40rem) { + grid-template-columns: repeat(2, 1fr); + } +} + +.wide { + grid-column: 1 / -1; +} + +.field { + display: flex; + flex-direction: column; + gap: 0.25rem; + font-size: $aux-text-size; + color: $label-gray; +} + +.hint { + color: $secondary-text-gray; +} + +.apart { + margin-right: auto; +} diff --git a/apps/client/src/features/app-settings/panel/automations-panel/NewAutomationDialog.tsx b/apps/client/src/features/app-settings/panel/automations-panel/NewAutomationDialog.tsx new file mode 100644 index 000000000..1577df755 --- /dev/null +++ b/apps/client/src/features/app-settings/panel/automations-panel/NewAutomationDialog.tsx @@ -0,0 +1,287 @@ +import type { Automation } from 'ontime-types'; +import { useState, type KeyboardEvent } from 'react'; +import { IoAdd, IoArrowBack, IoChevronForward, IoClose, IoSearch } from 'react-icons/io5'; + +import { addAutomation } from '../../../../common/api/automation'; +import { maybeAxiosError } from '../../../../common/api/utils'; +import Button from '../../../../common/components/buttons/Button'; +import IconButton from '../../../../common/components/buttons/IconButton'; +import Input from '../../../../common/components/input/input/Input'; +import Modal from '../../../../common/components/modal/Modal'; +import ScrollArea from '../../../../common/components/scroll-area/ScrollArea'; +import Select from '../../../../common/components/select/Select'; +import Tag from '../../../../common/components/tag/Tag'; +import { getLifecycleLabel } from '../../../../common/constants/timerLifecycle'; +import { summariseOutputs } from '../../../../common/utils/automationOutputs'; +import { cx } from '../../../../common/utils/styleUtils'; +import { isOntimeCloud } from '../../../../externals'; +import * as Panel from '../../panel-utils/PanelUtils'; +import { + defaultValues, + getAvailableRecipes, + recipeCategoryLabels, + recipeCategoryOrder, + type AutomationRecipe, + type RecipeValues, + validateRecipeValues, +} from './automationRecipes'; +import { makeTriggerTitle } from './automationUtils'; + +import style from './NewAutomationDialog.module.scss'; + +const availableRecipes = getAvailableRecipes(Boolean(isOntimeCloud)); + +interface NewAutomationDialogProps { + onClose: () => void; + onStartEmpty: () => void; + onCreated: (automation: Automation) => void; +} + +export default function NewAutomationDialog({ onClose, onStartEmpty, onCreated }: NewAutomationDialogProps) { + const [selected, setSelected] = useState(null); + + return selected === null ? ( + + ) : ( + setSelected(null)} onCreated={onCreated} /> + ); +} + +function matches(recipe: AutomationRecipe, query: string): boolean { + const haystack = [recipe.title, recipe.description, recipeCategoryLabels[recipe.category], ...(recipe.keywords ?? [])] + .join(' ') + .toLowerCase(); + return query + .toLowerCase() + .split(/\s+/) + .every((term) => haystack.includes(term)); +} + +interface RecipePickerProps { + onClose: () => void; + onStartEmpty: () => void; + onSelect: (recipe: AutomationRecipe) => void; +} + +function RecipePicker({ onClose, onStartEmpty, onSelect }: RecipePickerProps) { + const [query, setQuery] = useState(''); + + const trimmed = query.trim(); + const results = trimmed ? availableRecipes.filter((recipe) => matches(recipe, trimmed)) : availableRecipes; + + const handleSearchKey = (event: KeyboardEvent) => { + // the dialog is the only thing listening for escape, and losing it while clearing a + // search would be a bigger surprise than the search staying put + if (event.key === 'Escape' && trimmed.length > 0) { + event.stopPropagation(); + setQuery(''); + return; + } + + if (event.key === 'Enter' && results.length > 0) { + onSelect(results[0]); + } + }; + + return ( + +
+ + setQuery(event.target.value)} + onKeyDown={handleSearchKey} + placeholder='Search recipes, eg. QLab, OSC, message' + className={style.searchInput} + aria-label='Search recipes' + fluid + autoFocus + /> + {trimmed.length > 0 && ( + setQuery('')} + > + + + )} +
+ + {results.length === 0 && ( + + )} + + + {recipeCategoryOrder.map((category) => { + const inCategory = results.filter((recipe) => recipe.category === category); + if (inCategory.length === 0) { + return null; + } + + return ( +
+

{recipeCategoryLabels[category]}

+ {inCategory.map((recipe) => ( + + ))} +
+ ); + })} +
+ + } + footerElements={ + <> + + + + } + /> + ); +} + +interface RecipeSetupProps { + recipe: AutomationRecipe; + onClose: () => void; + onBack: () => void; + onCreated: (automation: Automation) => void; +} + +function RecipeSetup({ recipe, onClose, onBack, onCreated }: RecipeSetupProps) { + const [values, setValues] = useState(() => defaultValues(recipe)); + const [isCreating, setIsCreating] = useState(false); + const [error, setError] = useState(null); + + const automation = recipe.build(values); + const validationErrors = validateRecipeValues(recipe, values); + const isComplete = Object.keys(validationErrors).length === 0; + + const setValue = (name: string, value: string) => setValues((prev) => ({ ...prev, [name]: value })); + + const handleCreate = async () => { + setError(null); + setIsCreating(true); + try { + const created = await addAutomation( + automation, + recipe.triggers.map((cycle) => ({ + title: makeTriggerTitle(automation.title, cycle), + trigger: cycle, + })), + ); + onCreated(created); + } catch (error) { + setError(maybeAxiosError(error)); + } finally { + setIsCreating(false); + } + }; + + return ( + +

{recipe.description}

+ +
+
Runs on
+
+ {recipe.triggers.map((cycle) => ( + {getLifecycleLabel(cycle)} + ))} +
+
Sends
+
+ {summariseOutputs(automation.outputs).map(({ type, label, count }) => ( + {count > 1 ? `${label} ×${count}` : label} + ))} +
+
+ + {recipe.params.length > 0 && ( +
+ {recipe.params.map((param) => ( + + ))} +
+ )} + + + Created as a normal automation, which you can edit or delete like any other. + + + } + footerElements={ + <> + {error && {error}} + + + + + } + /> + ); +}