mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-21 06:59:09 +00:00
refactor(settings): polish settings UI
This commit is contained in:
committed by
Carlos Valente
parent
f0c284b708
commit
24be49ef66
@@ -1,24 +1,82 @@
|
|||||||
|
.section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background-color: $gray-1300;
|
||||||
|
border: 1px solid $gray-1100;
|
||||||
|
border-radius: $component-border-radius-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionHeader {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.separator {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
color: $gray-500;
|
||||||
|
font-size: $inner-section-text-size;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
|
||||||
|
&::before,
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
flex: 1;
|
||||||
|
border-top: 1px solid $gray-1100;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.textEntry {
|
.textEntry {
|
||||||
grid-area: input;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
color: $label-gray;
|
color: $label-gray;
|
||||||
}
|
}
|
||||||
|
|
||||||
.inlineEntry {
|
.inlineEntry {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-areas:
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
'label label'
|
align-items: center;
|
||||||
'input btn';
|
gap: 0.75rem;
|
||||||
grid-template-columns: 1fr auto;
|
|
||||||
column-gap: 0.5rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.redirect {
|
.redirect {
|
||||||
grid-area: btn;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.label {
|
.label {
|
||||||
color: $label-gray;
|
color: $gray-200;
|
||||||
|
font-size: $text-body-size;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
color: $gray-500;
|
||||||
|
font-size: $inner-section-text-size;
|
||||||
|
}
|
||||||
|
|
||||||
|
.origin {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: $gray-500;
|
||||||
|
font-size: $inner-section-text-size;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: $min-tablet) {
|
||||||
|
.inlineEntry {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.redirect {
|
||||||
|
align-self: flex-end;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { IoArrowForward } from 'react-icons/io5';
|
import { IoArrowForward } from 'react-icons/io5';
|
||||||
|
|
||||||
|
import { baseURI } from '../../../externals';
|
||||||
import { navigatorConstants } from '../../../viewerConfig';
|
import { navigatorConstants } from '../../../viewerConfig';
|
||||||
import useUrlPresets from '../../hooks-query/useUrlPresets';
|
import useUrlPresets from '../../hooks-query/useUrlPresets';
|
||||||
import { setClientRemote } from '../../hooks/useSocket';
|
import { setClientRemote } from '../../hooks/useSocket';
|
||||||
@@ -8,7 +9,7 @@ import Button from '../buttons/Button';
|
|||||||
import Dialog from '../dialog/Dialog';
|
import Dialog from '../dialog/Dialog';
|
||||||
import Info from '../info/Info';
|
import Info from '../info/Info';
|
||||||
import Input from '../input/input/Input';
|
import Input from '../input/input/Input';
|
||||||
import AppLink from '../link/app-link/AppLink';
|
import ExternalLink from '../link/external-link/ExternalLink';
|
||||||
import Select from '../select/Select';
|
import Select from '../select/Select';
|
||||||
|
|
||||||
import style from './RedirectClientModal.module.scss';
|
import style from './RedirectClientModal.module.scss';
|
||||||
@@ -44,6 +45,7 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
|
|||||||
};
|
};
|
||||||
|
|
||||||
const enabledPresets = data.filter((preset) => preset.enabled);
|
const enabledPresets = data.filter((preset) => preset.enabled);
|
||||||
|
const settingsUrl = `${window.location.origin}${baseURI}/?settings=sharing__presets`;
|
||||||
|
|
||||||
const viewOptions = [
|
const viewOptions = [
|
||||||
...navigatorConstants.map((view) => ({
|
...navigatorConstants.map((view) => ({
|
||||||
@@ -66,34 +68,46 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
|
|||||||
bodyElements={
|
bodyElements={
|
||||||
<>
|
<>
|
||||||
<Info>
|
<Info>
|
||||||
Remotely redirect the client to a different URL. <br />
|
<Info.Body>
|
||||||
Either by entering a custom path or selecting a URL Preset.
|
Remotely redirect the client to a different URL. Choose a custom path or select a URL Preset.
|
||||||
<br />
|
</Info.Body>
|
||||||
<br />
|
<Info.Footer>
|
||||||
<AppLink search='settings=sharing__presets'>Manage URL Presets</AppLink>
|
<ExternalLink href={settingsUrl}>Manage URL Presets</ExternalLink>
|
||||||
|
</Info.Footer>
|
||||||
</Info>
|
</Info>
|
||||||
<div className={style.inlineEntry}>
|
<section className={style.section}>
|
||||||
<span className={style.label}>Enter custom path</span>
|
<div className={style.sectionHeader}>
|
||||||
<label className={style.textEntry}>
|
<span className={style.label}>Enter custom path</span>
|
||||||
{origin}
|
<span className={style.description}>Type the path the client should open.</span>
|
||||||
<Input placeholder='eg. /timer' fluid value={path} onChange={(event) => setPath(event.target.value)} />
|
</div>
|
||||||
</label>
|
|
||||||
<Button
|
|
||||||
variant='primary'
|
|
||||||
aria-label='Redirect'
|
|
||||||
disabled={path === currentPath || path === ''}
|
|
||||||
className={style.redirect}
|
|
||||||
onClick={() => handleRedirect(path)}
|
|
||||||
>
|
|
||||||
Redirect
|
|
||||||
<IoArrowForward />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className={style.label}>Select View or URL Preset</span>
|
|
||||||
<div className={style.inlineEntry}>
|
<div className={style.inlineEntry}>
|
||||||
<label className={style.textEntry}>
|
<label className={style.textEntry}>
|
||||||
{origin}
|
<span className={style.origin}>{origin}</span>
|
||||||
|
<Input placeholder='eg. /timer' fluid value={path} onChange={(event) => setPath(event.target.value)} />
|
||||||
|
</label>
|
||||||
|
<Button
|
||||||
|
variant='primary'
|
||||||
|
aria-label='Redirect to custom path'
|
||||||
|
disabled={path === currentPath || path === ''}
|
||||||
|
className={style.redirect}
|
||||||
|
onClick={() => handleRedirect(path)}
|
||||||
|
>
|
||||||
|
Redirect
|
||||||
|
<IoArrowForward />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<div className={style.separator} role='separator'>
|
||||||
|
<span>Or</span>
|
||||||
|
</div>
|
||||||
|
<section className={style.section}>
|
||||||
|
<div className={style.sectionHeader}>
|
||||||
|
<span className={style.label}>Select view or URL Preset</span>
|
||||||
|
<span className={style.description}>Choose from the available destinations.</span>
|
||||||
|
</div>
|
||||||
|
<div className={style.inlineEntry}>
|
||||||
|
<label className={style.textEntry}>
|
||||||
|
<span className={style.origin}>{origin}</span>
|
||||||
<Select
|
<Select
|
||||||
fluid
|
fluid
|
||||||
options={viewOptions}
|
options={viewOptions}
|
||||||
@@ -115,7 +129,7 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
|
|||||||
Redirect <IoArrowForward />
|
Redirect <IoArrowForward />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
@use '../../../theme/ontimeStyles' as *;
|
||||||
|
|
||||||
|
.form {
|
||||||
|
padding: 0.25rem 0.5rem 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
color: $label-gray;
|
||||||
|
font-size: $inner-section-text-size;
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@ import { setClientRemote } from '../../hooks/useSocket';
|
|||||||
import Dialog from '../dialog/Dialog';
|
import Dialog from '../dialog/Dialog';
|
||||||
import Input from '../input/input/Input';
|
import Input from '../input/input/Input';
|
||||||
|
|
||||||
|
import style from './RenameClientModal.module.scss';
|
||||||
|
|
||||||
interface RenameClientModalProps {
|
interface RenameClientModalProps {
|
||||||
id: string;
|
id: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
@@ -31,9 +33,22 @@ export function RenameClientModal({ id, name: currentName = '', isOpen, onClose
|
|||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
title={`Rename Client: ${currentName}`}
|
title={`Rename Client: ${currentName}`}
|
||||||
showCloseButton
|
showCloseButton
|
||||||
|
showBackdrop
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
bodyElements={
|
bodyElements={
|
||||||
<Input height='large' placeholder='New name' value={name} onChange={(event) => setName(event.target.value)} />
|
<div className={style.form}>
|
||||||
|
<label className={style.label}>
|
||||||
|
Client name
|
||||||
|
<Input
|
||||||
|
height='large'
|
||||||
|
fluid
|
||||||
|
autoComplete='off'
|
||||||
|
placeholder='New name'
|
||||||
|
value={name}
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
footerElements={
|
footerElements={
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
.dialog {
|
.dialog {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 1rem;
|
top: 10vh;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
|
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
|
|||||||
@@ -11,6 +11,11 @@
|
|||||||
color: $ui-white;
|
color: $ui-white;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.active {
|
||||||
|
border: 1px solid $green-700;
|
||||||
|
color: $green-400;
|
||||||
|
}
|
||||||
|
|
||||||
.warning {
|
.warning {
|
||||||
background-color: $orange-1300;
|
background-color: $orange-1300;
|
||||||
color: $orange-300;
|
color: $orange-300;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import style from './Tag.module.scss';
|
|||||||
|
|
||||||
interface TagProps {
|
interface TagProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
variant?: 'default' | 'warning';
|
variant?: 'default' | 'active' | 'warning';
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Tag({ className, variant = 'default', children }: PropsWithChildren<TagProps>) {
|
export default function Tag({ className, variant = 'default', children }: PropsWithChildren<TagProps>) {
|
||||||
|
|||||||
@@ -1,24 +1,26 @@
|
|||||||
$inner-padding: 1rem;
|
$inner-padding: 1rem;
|
||||||
|
$card-padding: 2rem;
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
font-size: 2rem;
|
font-size: 1.5rem;
|
||||||
padding-bottom: 0.5rem;
|
padding-bottom: 0.5rem;
|
||||||
border-bottom: 1px solid $white-10;
|
border-bottom: 1px solid $white-10;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.subheader {
|
.subheader {
|
||||||
font-size: 1.5rem;
|
font-size: 1.125rem;
|
||||||
padding-bottom: 0.5rem;
|
padding-bottom: 0.5rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
font-size: 1.375rem;
|
font-size: 1rem;
|
||||||
padding: 0 2rem;
|
padding: 0 var(--panel-card-padding, #{$card-padding});
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -35,10 +37,12 @@ $inner-padding: 1rem;
|
|||||||
color: $ui-white;
|
color: $ui-white;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// width is owned by the panel content column, which caps and centres itself.
|
||||||
|
// sections must stay full width so top level cards line up with Panel.Header
|
||||||
|
// and nested sections fill their card rather than shrinking to fit
|
||||||
.section {
|
.section {
|
||||||
position: relative;
|
position: relative;
|
||||||
margin-top: 2rem;
|
margin-top: 2rem;
|
||||||
max-width: 1024px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.indent {
|
.indent {
|
||||||
@@ -51,8 +55,10 @@ $inner-padding: 1rem;
|
|||||||
}
|
}
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
|
--panel-card-padding: #{$card-padding};
|
||||||
|
|
||||||
position: relative;
|
position: relative;
|
||||||
padding: 2rem;
|
padding: var(--panel-card-padding);
|
||||||
background-color: $white-3;
|
background-color: $white-3;
|
||||||
border: 1px solid $gray-1100;
|
border: 1px solid $gray-1100;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
@@ -76,10 +82,10 @@ $inner-padding: 1rem;
|
|||||||
color: $error-red;
|
color: $error-red;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// tables scroll with the panel rather than owning a nested scroll area,
|
||||||
|
// which keeps the sticky table head anchored to the panel viewport
|
||||||
.pad {
|
.pad {
|
||||||
padding: 0 2rem;
|
padding: 0 var(--panel-card-padding, #{$card-padding});
|
||||||
max-height: 550px;
|
|
||||||
overflow-y: scroll;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.table {
|
.table {
|
||||||
@@ -97,10 +103,13 @@ $inner-padding: 1rem;
|
|||||||
}
|
}
|
||||||
|
|
||||||
th {
|
th {
|
||||||
font-weight: 400;
|
font-weight: 600;
|
||||||
color: $gray-400;
|
color: $gray-300;
|
||||||
|
font-size: calc(1rem - 3px);
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
vertical-align: bottom;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
text-transform: capitalize;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
th,
|
th,
|
||||||
@@ -112,6 +121,17 @@ $inner-padding: 1rem;
|
|||||||
background-color: $white-1;
|
background-color: $white-1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// active rows use a panel-local class today; keep the treatment here so all
|
||||||
|
// settings tables share the same state without each panel owning its colours
|
||||||
|
tr[class*='current'] {
|
||||||
|
background-color: rgba($blue-500, 0.16);
|
||||||
|
|
||||||
|
> td:first-child {
|
||||||
|
box-shadow: inset 3px 0 $blue-400;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// allow highlighting table elements
|
// allow highlighting table elements
|
||||||
tr[data-warn='true'],
|
tr[data-warn='true'],
|
||||||
td[data-warn='true'] {
|
td[data-warn='true'] {
|
||||||
@@ -120,7 +140,7 @@ $inner-padding: 1rem;
|
|||||||
}
|
}
|
||||||
|
|
||||||
.listGroup {
|
.listGroup {
|
||||||
padding: 0 2rem;
|
padding: 0 var(--panel-card-padding, #{$card-padding});
|
||||||
|
|
||||||
> li:not(:last-child) {
|
> li:not(:last-child) {
|
||||||
border-bottom: 1px solid $white-10;
|
border-bottom: 1px solid $white-10;
|
||||||
@@ -133,6 +153,17 @@ $inner-padding: 1rem;
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
padding: 0.5rem 0;
|
padding: 0.5rem 0;
|
||||||
|
|
||||||
|
&.interactive {
|
||||||
|
margin-inline: calc(var(--panel-card-padding, #{$card-padding}) * -1);
|
||||||
|
padding: 0.75rem var(--panel-card-padding, #{$card-padding});
|
||||||
|
transition: background-color $transition-time-action;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:focus-within {
|
||||||
|
background-color: $white-3;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.fieldTitle {
|
.fieldTitle {
|
||||||
@@ -163,7 +194,7 @@ $inner-padding: 1rem;
|
|||||||
.divider {
|
.divider {
|
||||||
border: none;
|
border: none;
|
||||||
border-top: 1px solid $white-10;
|
border-top: 1px solid $white-10;
|
||||||
margin: 1rem -2rem;
|
margin: 1rem calc(var(--panel-card-padding, #{$card-padding}) * -1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.overlay {
|
.overlay {
|
||||||
@@ -188,19 +219,35 @@ $inner-padding: 1rem;
|
|||||||
animation: animloader 1s ease-in infinite;
|
animation: animloader 1s ease-in infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty {
|
.emptyState {
|
||||||
background-color: $black-10;
|
padding: 3rem 1.5rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: $muted-gray;
|
}
|
||||||
|
|
||||||
td {
|
.emptyMessage {
|
||||||
padding-block: 5rem;
|
width: min(30rem, 100%);
|
||||||
}
|
margin-inline: auto;
|
||||||
|
}
|
||||||
|
|
||||||
button {
|
.emptyTitle {
|
||||||
margin-top: 1rem;
|
margin-bottom: 0.25rem;
|
||||||
margin-inline: auto;
|
color: rgba($gray-200, 0.72);
|
||||||
}
|
font-size: calc(1rem + 2px);
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyBody {
|
||||||
|
color: rgba($gray-200, 0.55);
|
||||||
|
font-size: calc(1rem - 3px);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyAction {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.inlineElements {
|
.inlineElements {
|
||||||
@@ -239,14 +286,3 @@ $inner-padding: 1rem;
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes animloader {
|
|
||||||
0% {
|
|
||||||
transform: scale(0);
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
transform: scale(1);
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { HTMLAttributes, PropsWithChildren, ReactNode } from 'react';
|
import { HTMLAttributes, PropsWithChildren, ReactNode } from 'react';
|
||||||
import { IoAdd } from 'react-icons/io5';
|
|
||||||
|
|
||||||
import Button from '../../../common/components/buttons/Button';
|
|
||||||
import { cx } from '../../../common/utils/styleUtils';
|
import { cx } from '../../../common/utils/styleUtils';
|
||||||
|
|
||||||
import style from './PanelUtils.module.scss';
|
import style from './PanelUtils.module.scss';
|
||||||
@@ -62,16 +60,29 @@ export function Table({ className, children }: { className?: string; children: R
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TableEmpty({ label, handleClick }: { label?: string; handleClick?: () => void }) {
|
export interface EmptyStateProps {
|
||||||
|
title: string;
|
||||||
|
description?: ReactNode;
|
||||||
|
action?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmptyState({ title, description, action }: EmptyStateProps) {
|
||||||
return (
|
return (
|
||||||
<tr className={style.empty}>
|
<div className={style.emptyState}>
|
||||||
|
<div className={style.emptyMessage}>
|
||||||
|
<div className={style.emptyTitle}>{title}</div>
|
||||||
|
{description && <div className={style.emptyBody}>{description}</div>}
|
||||||
|
{action && <div className={style.emptyAction}>{action}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TableEmpty(props: EmptyStateProps) {
|
||||||
|
return (
|
||||||
|
<tr>
|
||||||
<td colSpan={99}>
|
<td colSpan={99}>
|
||||||
<div>{label ?? 'No data yet'}</div>
|
<EmptyState {...props} />
|
||||||
{handleClick && (
|
|
||||||
<Button onClick={handleClick} disabled={!handleClick} variant='primary'>
|
|
||||||
New <IoAdd />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
@@ -81,8 +92,8 @@ export function ListGroup({ className, children }: { className?: string; childre
|
|||||||
return <ul className={cx([style.listGroup, className])}>{children}</ul>;
|
return <ul className={cx([style.listGroup, className])}>{children}</ul>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ListItem({ children }: { children: ReactNode }) {
|
export function ListItem({ children, interactive = false }: { children: ReactNode; interactive?: boolean }) {
|
||||||
return <li className={style.listItem}>{children}</li>;
|
return <li className={cx([style.listItem, interactive && style.interactive])}>{children}</li>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Field({
|
export function Field({
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
|
import Info from '../../../../common/components/info/Info';
|
||||||
import {
|
import {
|
||||||
buyMeACoffeeUrl,
|
buyMeACoffeeUrl,
|
||||||
discordUrl,
|
discordUrl,
|
||||||
@@ -11,6 +11,8 @@ import {
|
|||||||
} from '../../../../externals';
|
} from '../../../../externals';
|
||||||
import * as Panel from '../../panel-utils/PanelUtils';
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
import AppVersion from './AppVersion';
|
import AppVersion from './AppVersion';
|
||||||
|
import CloudPanel from './CloudPanel';
|
||||||
|
import ExternalLinkRow from './ExternalLinkRow';
|
||||||
|
|
||||||
export default function AboutPanel() {
|
export default function AboutPanel() {
|
||||||
return (
|
return (
|
||||||
@@ -19,28 +21,49 @@ export default function AboutPanel() {
|
|||||||
<Panel.Section>
|
<Panel.Section>
|
||||||
<Panel.Card>
|
<Panel.Card>
|
||||||
<Panel.SubHeader>Ontime</Panel.SubHeader>
|
<Panel.SubHeader>Ontime</Panel.SubHeader>
|
||||||
<Panel.Paragraph>
|
<Panel.Divider />
|
||||||
Free, open-source software for managing rundowns and event timers
|
<Info>Free, open-source software for managing rundowns and event timers.</Info>
|
||||||
<ExternalLink href={websiteUrl}>www.getontime.no</ExternalLink>
|
<Panel.ListGroup>
|
||||||
</Panel.Paragraph>
|
<ExternalLinkRow href={websiteUrl} title='www.getontime.no' description='App site and downloads' />
|
||||||
<Panel.Paragraph>
|
<ExternalLinkRow
|
||||||
Considering sponsoring our work
|
href={githubSponsorUrl}
|
||||||
<ExternalLink href={githubSponsorUrl}>GitHub Sponsors</ExternalLink>
|
title='GitHub Sponsors'
|
||||||
<ExternalLink href={buyMeACoffeeUrl}>Buy Me a Coffee</ExternalLink>
|
description='Support development through GitHub'
|
||||||
</Panel.Paragraph>
|
/>
|
||||||
<Panel.Paragraph>
|
<ExternalLinkRow
|
||||||
If you are looking for an online version of Ontime, consider trying out our cloud service
|
href={buyMeACoffeeUrl}
|
||||||
<ExternalLink href={websiteUrl}>www.getontime.no</ExternalLink>
|
title='Buy Me a Coffee'
|
||||||
</Panel.Paragraph>
|
description='Make a one-time contribution'
|
||||||
|
/>
|
||||||
|
</Panel.ListGroup>
|
||||||
|
</Panel.Card>
|
||||||
|
</Panel.Section>
|
||||||
|
<CloudPanel />
|
||||||
|
<Panel.Section>
|
||||||
|
<Panel.Card>
|
||||||
|
<Panel.SubHeader>Current version</Panel.SubHeader>
|
||||||
|
<Panel.Divider />
|
||||||
|
<Panel.ListGroup>
|
||||||
|
<AppVersion />
|
||||||
|
</Panel.ListGroup>
|
||||||
|
</Panel.Card>
|
||||||
|
</Panel.Section>
|
||||||
|
<Panel.Section>
|
||||||
|
<Panel.Card>
|
||||||
|
<Panel.SubHeader>Helpful links</Panel.SubHeader>
|
||||||
|
<Panel.Divider />
|
||||||
|
<Panel.ListGroup>
|
||||||
|
<ExternalLinkRow
|
||||||
|
href={documentationUrl}
|
||||||
|
title='Read the docs'
|
||||||
|
description='Setup guides and feature documentation'
|
||||||
|
/>
|
||||||
|
<ExternalLinkRow href={githubUrl} title='GitHub' description='Report issues or contribute to Ontime' />
|
||||||
|
<ExternalLinkRow href={youtubeUrl} title='YouTube' description='Watch tutorials and feature walkthroughs' />
|
||||||
|
<ExternalLinkRow href={discordUrl} title='Discord' description='Get help and share suggestions' />
|
||||||
|
<ExternalLinkRow href={subredditUrl} title='Reddit' description='Join the Ontime community discussion' />
|
||||||
|
</Panel.ListGroup>
|
||||||
</Panel.Card>
|
</Panel.Card>
|
||||||
<Panel.SubHeader>Current version</Panel.SubHeader>
|
|
||||||
<AppVersion />
|
|
||||||
<Panel.SubHeader>Links</Panel.SubHeader>
|
|
||||||
<ExternalLink href={documentationUrl}>Read the docs</ExternalLink>
|
|
||||||
<ExternalLink href={githubUrl}>Follow the project on GitHub</ExternalLink>
|
|
||||||
<ExternalLink href={discordUrl}>Discord server</ExternalLink>
|
|
||||||
<ExternalLink href={youtubeUrl}>YouTube channel</ExternalLink>
|
|
||||||
<ExternalLink href={subredditUrl}>Subreddit</ExternalLink>
|
|
||||||
</Panel.Section>
|
</Panel.Section>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
|
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
|
||||||
import useAppVersion from '../../../../common/hooks-query/useAppVersion';
|
import useAppVersion from '../../../../common/hooks-query/useAppVersion';
|
||||||
import { appVersion, isOntimeCloud } from '../../../../externals';
|
import { appVersion, isOntimeCloud, websiteUrl } from '../../../../externals';
|
||||||
import * as Panel from '../../panel-utils/PanelUtils';
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
|
|
||||||
export default function AppVersion() {
|
export default function AppVersion() {
|
||||||
@@ -8,28 +8,33 @@ export default function AppVersion() {
|
|||||||
|
|
||||||
if (isError) {
|
if (isError) {
|
||||||
return (
|
return (
|
||||||
<Panel.Paragraph>
|
<Panel.ListItem>
|
||||||
{`You are currently using Ontime version ${appVersion}`}
|
<Panel.Field title={`Ontime ${appVersion}`} description='' error='Could not fetch version information' />
|
||||||
<Panel.Error>Could not fetch version information</Panel.Error>
|
</Panel.ListItem>
|
||||||
</Panel.Paragraph>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.hasUpdates) {
|
if (data.hasUpdates) {
|
||||||
return (
|
return (
|
||||||
<Panel.Paragraph>
|
<Panel.ListItem>
|
||||||
{`You are currently using Ontime version ${appVersion}.`}
|
<Panel.Field
|
||||||
<br />
|
title={`Ontime ${appVersion}`}
|
||||||
<br />
|
description={
|
||||||
{`A new version ${data.version} is available.`} <br />
|
isOntimeCloud
|
||||||
{isOntimeCloud ? (
|
? `Version ${data.version} is available. Restart your stage to update.`
|
||||||
'You can restart your stage to get the latest available version.'
|
: `Version ${data.version} is available.`
|
||||||
) : (
|
}
|
||||||
<ExternalLink href={data.url}>Please visit the release page to download</ExternalLink>
|
/>
|
||||||
|
{!isOntimeCloud && (
|
||||||
|
<ExternalLink href={websiteUrl}>Visit Ontime's page to download the latest version.</ExternalLink>
|
||||||
)}
|
)}
|
||||||
</Panel.Paragraph>
|
</Panel.ListItem>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <Panel.Paragraph>{`You are currently using the latest version of Ontime: ${appVersion}`}</Panel.Paragraph>;
|
return (
|
||||||
|
<Panel.ListItem>
|
||||||
|
<Panel.Field title={`Ontime ${appVersion}`} description='You are using the latest version.' />
|
||||||
|
</Panel.ListItem>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
.card {
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-color: rgba($action-blue, 0.42);
|
||||||
|
background-color: rgba($action-blue, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
color: $action-text-color;
|
||||||
|
font-size: calc(1rem - 3px);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
max-width: 33rem;
|
||||||
|
margin-top: 0.9rem;
|
||||||
|
color: $gray-300;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portability {
|
||||||
|
padding-top: 0.75rem;
|
||||||
|
border-top: 1px solid $white-10;
|
||||||
|
color: $gray-400;
|
||||||
|
font-size: calc(1rem - 2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.benefits {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 0.75rem 1rem;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
|
||||||
|
li {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.5rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
margin-top: 0.1rem;
|
||||||
|
color: $action-text-color;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: $min-tablet) {
|
||||||
|
.benefits {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { IoCheckmarkCircle } from 'react-icons/io5';
|
||||||
|
|
||||||
|
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
|
||||||
|
import { isOntimeCloud, websiteUrl } from '../../../../externals';
|
||||||
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
|
|
||||||
|
import style from './CloudPanel.module.scss';
|
||||||
|
|
||||||
|
export default function CloudPanel() {
|
||||||
|
if (isOntimeCloud) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Panel.Section>
|
||||||
|
<Panel.Card className={style.card}>
|
||||||
|
<span className={style.eyebrow}>Ontime Cloud</span>
|
||||||
|
<h3 className={style.title}>Run Ontime online with zero setup</h3>
|
||||||
|
<p className={style.description}>
|
||||||
|
<span>
|
||||||
|
Ontime Cloud lets you run and share Ontime online with your team. Subscriptions support continued
|
||||||
|
development while Ontime remains fully open-source and self-hostable.
|
||||||
|
</span>
|
||||||
|
<span className={style.portability}>
|
||||||
|
Project files work locally and in Cloud, so your shows can move freely.
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<ul className={style.benefits}>
|
||||||
|
<li>
|
||||||
|
<IoCheckmarkCircle />
|
||||||
|
Start in minutes, with no setup or maintenance
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<IoCheckmarkCircle />
|
||||||
|
Coordinate multi-track rundowns with Overview views
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<IoCheckmarkCircle />
|
||||||
|
Manage every instance from one dashboard
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<IoCheckmarkCircle />
|
||||||
|
Share secure read-only views with collaborators
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div className={style.actions}>
|
||||||
|
<ExternalLink href={websiteUrl}>Learn more about Ontime Cloud</ExternalLink>
|
||||||
|
</div>
|
||||||
|
</Panel.Card>
|
||||||
|
</Panel.Section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
.link {
|
||||||
|
width: calc(100% + (var(--panel-card-padding, 2rem) * 2));
|
||||||
|
margin: -0.75rem calc(var(--panel-card-padding, 2rem) * -1);
|
||||||
|
padding: 0.75rem var(--panel-card-padding, 2rem);
|
||||||
|
|
||||||
|
> :first-child {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.action {
|
||||||
|
color: $action-text-color;
|
||||||
|
font-size: calc(1rem - 2px);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
|
||||||
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
|
|
||||||
|
import style from './ExternalLinkRow.module.scss';
|
||||||
|
|
||||||
|
interface ExternalLinkRowProps {
|
||||||
|
href: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ExternalLinkRow({ href, title, description }: ExternalLinkRowProps) {
|
||||||
|
return (
|
||||||
|
<Panel.ListItem interactive>
|
||||||
|
<ExternalLink href={href} className={style.link}>
|
||||||
|
<Panel.Field title={title} description={description} />
|
||||||
|
<span className={style.action}>Open</span>
|
||||||
|
</ExternalLink>
|
||||||
|
</Panel.ListItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
+8
-1
@@ -1,6 +1,13 @@
|
|||||||
.outerColumn {
|
.outerColumn {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
gap: 2rem;
|
gap: 2rem;
|
||||||
margin-bottom: 2rem;
|
font-size: calc(1rem - 1px);
|
||||||
|
color: $ui-white;
|
||||||
|
|
||||||
|
// the shared modal body owns scrolling for this regular form modal
|
||||||
|
min-height: 100%;
|
||||||
|
padding-block: 0.5rem;
|
||||||
|
|
||||||
h3 {
|
h3 {
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
|
|||||||
+309
-279
@@ -19,12 +19,12 @@ import IconButton from '../../../../common/components/buttons/IconButton';
|
|||||||
import Info from '../../../../common/components/info/Info';
|
import Info from '../../../../common/components/info/Info';
|
||||||
import Input from '../../../../common/components/input/input/Input';
|
import Input from '../../../../common/components/input/input/Input';
|
||||||
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
|
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
|
||||||
|
import Modal from '../../../../common/components/modal/Modal';
|
||||||
import RadioGroup from '../../../../common/components/radio-group/RadioGroup';
|
import RadioGroup from '../../../../common/components/radio-group/RadioGroup';
|
||||||
import Select from '../../../../common/components/select/Select';
|
import Select from '../../../../common/components/select/Select';
|
||||||
import Tag from '../../../../common/components/tag/Tag';
|
import Tag from '../../../../common/components/tag/Tag';
|
||||||
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
|
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
|
||||||
import useCustomFields from '../../../../common/hooks-query/useCustomFields';
|
import useCustomFields from '../../../../common/hooks-query/useCustomFields';
|
||||||
import { preventEscape } from '../../../../common/utils/keyEvent';
|
|
||||||
import { startsWithHttp } from '../../../../common/utils/regex';
|
import { startsWithHttp } from '../../../../common/utils/regex';
|
||||||
import * as Panel from '../../panel-utils/PanelUtils';
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
import { isAutomation, makeFieldList } from './automationUtils';
|
import { isAutomation, makeFieldList } from './automationUtils';
|
||||||
@@ -34,6 +34,7 @@ import TemplateInput from './template-input/TemplateInput';
|
|||||||
import style from './AutomationForm.module.scss';
|
import style from './AutomationForm.module.scss';
|
||||||
|
|
||||||
const integrationsDocsUrl = 'https://docs.getontime.no/api/automation/#using-variables-in-automation';
|
const integrationsDocsUrl = 'https://docs.getontime.no/api/automation/#using-variables-in-automation';
|
||||||
|
const formId = 'automation-form';
|
||||||
|
|
||||||
interface AutomationFormProps {
|
interface AutomationFormProps {
|
||||||
automation: Automation | AutomationDTO;
|
automation: Automation | AutomationDTO;
|
||||||
@@ -185,291 +186,320 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
|
|||||||
const canSubmit = !isSubmitting && isDirty && isValid;
|
const canSubmit = !isSubmitting && isDirty && isValid;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Panel.Indent
|
<Modal
|
||||||
as='form'
|
isOpen
|
||||||
name='automation-form'
|
onClose={onClose}
|
||||||
onSubmit={handleSubmit(onSubmit)}
|
showBackdrop
|
||||||
className={style.outerColumn}
|
showCloseButton
|
||||||
onKeyDown={(event) => preventEscape(event, onClose)}
|
title={isEdit ? 'Edit automation' : 'Create automation'}
|
||||||
>
|
bodyElements={
|
||||||
<Panel.SubHeader>{isEdit ? 'Edit automation' : 'Create automation'}</Panel.SubHeader>
|
<form id={formId} onSubmit={handleSubmit(onSubmit)} className={style.outerColumn}>
|
||||||
<div className={style.innerSection}>
|
<div className={style.innerColumn}>
|
||||||
<h3>Automation options</h3>
|
<h3>Automation options</h3>
|
||||||
<div className={style.titleSection}>
|
<div className={style.titleSection}>
|
||||||
<label>
|
<label>
|
||||||
Title
|
Title
|
||||||
<Input
|
<Input
|
||||||
{...register('title', { required: { value: true, message: 'Required field' } })}
|
{...register('title', { required: { value: true, message: 'Required field' } })}
|
||||||
fluid
|
fluid
|
||||||
placeholder='Load preset'
|
placeholder='Load preset'
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<Panel.Error>{errors.title?.message}</Panel.Error>
|
<Panel.Error>{errors.title?.message}</Panel.Error>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={style.innerSection}>
|
|
||||||
<h3>Filters (optional)</h3>
|
|
||||||
<div className={style.ruleSection}>
|
|
||||||
<label>
|
|
||||||
Trigger outputs if
|
|
||||||
<RadioGroup
|
|
||||||
orientation='horizontal'
|
|
||||||
value={watch('filterRule')}
|
|
||||||
onValueChange={(value) => setValue('filterRule', value, { shouldDirty: true })}
|
|
||||||
items={[
|
|
||||||
{ value: 'all', label: 'All filters pass' },
|
|
||||||
{ value: 'any', label: 'Any filter passes' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
{fieldFilters.map((field, index) => {
|
|
||||||
const key = `filters.${index}.field.${field.id}`;
|
|
||||||
return (
|
|
||||||
<div key={key} className={style.filterSection}>
|
|
||||||
<label>
|
|
||||||
Runtime data source
|
|
||||||
<Select<string | null>
|
|
||||||
// need to normalize '' to null for the Select to show the placeholder
|
|
||||||
value={watch(`filters.${index}.field`) || null}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
if (value === null) return;
|
|
||||||
setValue(`filters.${index}.field`, value, { shouldDirty: true });
|
|
||||||
}}
|
|
||||||
options={fieldList.map(({ value, label }) => ({
|
|
||||||
value,
|
|
||||||
label,
|
|
||||||
disabled: value === null,
|
|
||||||
}))}
|
|
||||||
aria-label='Event field'
|
|
||||||
/>
|
|
||||||
<Panel.Error>{errors.filters?.[index]?.field?.message}</Panel.Error>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Matching condition
|
|
||||||
<Select
|
|
||||||
value={watch(`filters.${index}.operator`)}
|
|
||||||
onValueChange={(value: string | null) => {
|
|
||||||
if (value === null) return;
|
|
||||||
setValue(
|
|
||||||
`filters.${index}.operator`,
|
|
||||||
value as 'equals' | 'not_equals' | 'greater_than' | 'less_than' | 'contains' | 'not_contains',
|
|
||||||
{ shouldDirty: true },
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
options={[
|
|
||||||
{ value: 'equals', label: 'equals' },
|
|
||||||
{ value: 'not_equals', label: 'not equals' },
|
|
||||||
{ value: 'contains', label: 'contains' },
|
|
||||||
]}
|
|
||||||
aria-label='Operator'
|
|
||||||
/>
|
|
||||||
<Panel.Error>{errors.filters?.[index]?.operator?.message}</Panel.Error>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Value to match
|
|
||||||
<Input {...register(`filters.${index}.value`)} fluid placeholder='<empty / no value>' />
|
|
||||||
</label>
|
|
||||||
<div>
|
|
||||||
<span> </span>
|
|
||||||
<div>
|
|
||||||
<IconButton aria-label='Delete' variant='ghosted-destructive' onClick={() => removeFilter(index)}>
|
|
||||||
<IoTrash />
|
|
||||||
</IconButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
<div>
|
|
||||||
<Button onClick={handleAddNewFilter}>
|
|
||||||
Add filter <IoAdd />
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={style.innerColumn}>
|
<div className={style.innerColumn}>
|
||||||
<h3>Outputs</h3>
|
<h3>Filters (optional)</h3>
|
||||||
<Info>
|
<div className={style.ruleSection}>
|
||||||
Automation outputs can be used to send data from Ontime to external software <br />
|
<label>
|
||||||
or to change properties of Ontime itself. <br /> <br />
|
Trigger outputs if
|
||||||
Use Ontime runtime data in these fields with template strings. Type {'{{'} to see autocomplete, or{' '}
|
<RadioGroup
|
||||||
<ExternalLink href={integrationsDocsUrl}>read the docs</ExternalLink>
|
orientation='horizontal'
|
||||||
</Info>
|
value={watch('filterRule')}
|
||||||
|
onValueChange={(value) => setValue('filterRule', value, { shouldDirty: true })}
|
||||||
{fieldOutputs.map((output, index) => {
|
items={[
|
||||||
if (isOSCOutput(output)) {
|
{ value: 'all', label: 'All filters pass' },
|
||||||
const rowErrors = errors.outputs?.[index] as
|
{ value: 'any', label: 'Any filter passes' },
|
||||||
| {
|
]}
|
||||||
targetIP?: { message?: string };
|
/>
|
||||||
targetPort?: { message?: string };
|
</label>
|
||||||
address?: { message?: string };
|
{fieldFilters.map((field, index) => {
|
||||||
args?: { message?: string };
|
const key = `filters.${index}.field.${field.id}`;
|
||||||
}
|
return (
|
||||||
| undefined;
|
<div key={key} className={style.filterSection}>
|
||||||
|
<label>
|
||||||
return (
|
Runtime data source
|
||||||
<div key={output.id} className={style.outputCard}>
|
<Select<string | null>
|
||||||
<Tag>OSC</Tag>
|
// need to normalize '' to null for the Select to show the placeholder
|
||||||
<div className={style.oscSection}>
|
value={watch(`filters.${index}.field`) || null}
|
||||||
<label>
|
onValueChange={(value) => {
|
||||||
Target IP
|
if (value === null) return;
|
||||||
<Input
|
setValue(`filters.${index}.field`, value, { shouldDirty: true });
|
||||||
{...register(`outputs.${index}.targetIP`, {
|
}}
|
||||||
required: { value: true, message: 'Required field' },
|
options={fieldList.map(({ value, label }) => ({
|
||||||
})}
|
value,
|
||||||
fluid
|
label,
|
||||||
placeholder='127.0.0.1'
|
disabled: value === null,
|
||||||
/>
|
}))}
|
||||||
<Panel.Error>{rowErrors?.targetIP?.message}</Panel.Error>
|
aria-label='Event field'
|
||||||
</label>
|
/>
|
||||||
<label>
|
<Panel.Error>{errors.filters?.[index]?.field?.message}</Panel.Error>
|
||||||
Target Port
|
</label>
|
||||||
<Input
|
<label>
|
||||||
{...register(`outputs.${index}.targetPort`, {
|
Matching condition
|
||||||
required: { value: true, message: 'Required field' },
|
<Select
|
||||||
setValueAs: (value) => (value === '' ? 0 : Number(value)),
|
value={watch(`filters.${index}.operator`)}
|
||||||
max: { value: 65535, message: 'Port must be within range 1024 - 65535' },
|
onValueChange={(value: string | null) => {
|
||||||
min: { value: 1024, message: 'Port must be within range 1024 - 65535' },
|
if (value === null) return;
|
||||||
})}
|
setValue(
|
||||||
fluid
|
`filters.${index}.operator`,
|
||||||
type='number'
|
value as
|
||||||
maxLength={5}
|
| 'equals'
|
||||||
placeholder='8000'
|
| 'not_equals'
|
||||||
/>
|
| 'greater_than'
|
||||||
<Panel.Error>{rowErrors?.targetPort?.message}</Panel.Error>
|
| 'less_than'
|
||||||
</label>
|
| 'contains'
|
||||||
<label>
|
| 'not_contains',
|
||||||
Address
|
{ shouldDirty: true },
|
||||||
<TemplateInput
|
);
|
||||||
{...register(`outputs.${index}.address`)}
|
}}
|
||||||
value={output.address}
|
options={[
|
||||||
fluid
|
{ value: 'equals', label: 'equals' },
|
||||||
placeholder='/cue/start'
|
{ value: 'not_equals', label: 'not equals' },
|
||||||
/>
|
{ value: 'contains', label: 'contains' },
|
||||||
<Panel.Error>{rowErrors?.address?.message}</Panel.Error>
|
]}
|
||||||
</label>
|
aria-label='Operator'
|
||||||
<label>
|
/>
|
||||||
Arguments
|
<Panel.Error>{errors.filters?.[index]?.operator?.message}</Panel.Error>
|
||||||
<TemplateInput {...register(`outputs.${index}.args`)} value={output.args} fluid placeholder='1' />
|
</label>
|
||||||
<Panel.Error>{rowErrors?.args?.message}</Panel.Error>
|
<label>
|
||||||
</label>
|
Value to match
|
||||||
<div>
|
<Input {...register(`filters.${index}.value`)} fluid placeholder='<empty / no value>' />
|
||||||
<span> </span>
|
</label>
|
||||||
<Panel.InlineElements relation='inner'>
|
<div>
|
||||||
<Button variant='ghosted-white' onClick={() => handleTestOSCOutput(index)}>
|
<span> </span>
|
||||||
Test
|
<div>
|
||||||
</Button>
|
<IconButton
|
||||||
<IconButton aria-label='Delete' variant='ghosted-destructive' onClick={() => removeOutput(index)}>
|
aria-label='Delete'
|
||||||
<IoTrash />
|
variant='ghosted-destructive'
|
||||||
</IconButton>
|
onClick={() => removeFilter(index)}
|
||||||
</Panel.InlineElements>
|
>
|
||||||
|
<IoTrash />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
|
})}
|
||||||
|
<div>
|
||||||
|
<Button onClick={handleAddNewFilter}>
|
||||||
|
Add filter <IoAdd />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
</div>
|
||||||
}
|
</div>
|
||||||
if (isHTTPOutput(output)) {
|
|
||||||
const rowErrors = errors.outputs?.[index] as
|
<div className={style.innerColumn}>
|
||||||
| {
|
<h3>Outputs</h3>
|
||||||
url?: { message?: string };
|
<Info>
|
||||||
}
|
Automation outputs can be used to send data from Ontime to external software <br />
|
||||||
| undefined;
|
or to change properties of Ontime itself. <br /> <br />
|
||||||
return (
|
Use Ontime runtime data in these fields with template strings. Type {'{{'} to see autocomplete, or{' '}
|
||||||
<div key={output.id} className={style.outputCard}>
|
<ExternalLink href={integrationsDocsUrl}>read the docs</ExternalLink>
|
||||||
<Tag>HTTP</Tag>
|
</Info>
|
||||||
<div className={style.httpSection}>
|
|
||||||
<label>
|
{fieldOutputs.map((output, index) => {
|
||||||
Target URL
|
if (isOSCOutput(output)) {
|
||||||
<TemplateInput
|
const rowErrors = errors.outputs?.[index] as
|
||||||
{...register(`outputs.${index}.url`, {
|
| {
|
||||||
required: { value: true, message: 'Required field' },
|
targetIP?: { message?: string };
|
||||||
pattern: {
|
targetPort?: { message?: string };
|
||||||
value: startsWithHttp,
|
address?: { message?: string };
|
||||||
message: 'HTTP messages should target http:// or https://',
|
args?: { message?: string };
|
||||||
},
|
}
|
||||||
})}
|
| undefined;
|
||||||
value={output.url}
|
|
||||||
fluid
|
return (
|
||||||
placeholder='http://127.0.0.1/start/1'
|
<div key={output.id} className={style.outputCard}>
|
||||||
/>
|
<Tag>OSC</Tag>
|
||||||
<Panel.Error>{rowErrors?.url?.message}</Panel.Error>
|
<div className={style.oscSection}>
|
||||||
</label>
|
<label>
|
||||||
<div>
|
Target IP
|
||||||
<span> </span>
|
<Input
|
||||||
<Panel.InlineElements relation='inner'>
|
{...register(`outputs.${index}.targetIP`, {
|
||||||
<Button variant='ghosted-white' onClick={() => handleTestHTTPOutput(index)}>
|
required: { value: true, message: 'Required field' },
|
||||||
Test
|
})}
|
||||||
</Button>
|
fluid
|
||||||
<IconButton aria-label='Delete' variant='ghosted-destructive' onClick={() => removeOutput(index)}>
|
placeholder='127.0.0.1'
|
||||||
<IoTrash />
|
/>
|
||||||
</IconButton>
|
<Panel.Error>{rowErrors?.targetIP?.message}</Panel.Error>
|
||||||
</Panel.InlineElements>
|
</label>
|
||||||
|
<label>
|
||||||
|
Target Port
|
||||||
|
<Input
|
||||||
|
{...register(`outputs.${index}.targetPort`, {
|
||||||
|
required: { value: true, message: 'Required field' },
|
||||||
|
setValueAs: (value) => (value === '' ? 0 : Number(value)),
|
||||||
|
max: { value: 65535, message: 'Port must be within range 1024 - 65535' },
|
||||||
|
min: { value: 1024, message: 'Port must be within range 1024 - 65535' },
|
||||||
|
})}
|
||||||
|
fluid
|
||||||
|
type='number'
|
||||||
|
maxLength={5}
|
||||||
|
placeholder='8000'
|
||||||
|
/>
|
||||||
|
<Panel.Error>{rowErrors?.targetPort?.message}</Panel.Error>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Address
|
||||||
|
<TemplateInput
|
||||||
|
{...register(`outputs.${index}.address`)}
|
||||||
|
value={output.address}
|
||||||
|
fluid
|
||||||
|
placeholder='/cue/start'
|
||||||
|
/>
|
||||||
|
<Panel.Error>{rowErrors?.address?.message}</Panel.Error>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Arguments
|
||||||
|
<TemplateInput
|
||||||
|
{...register(`outputs.${index}.args`)}
|
||||||
|
value={output.args}
|
||||||
|
fluid
|
||||||
|
placeholder='1'
|
||||||
|
/>
|
||||||
|
<Panel.Error>{rowErrors?.args?.message}</Panel.Error>
|
||||||
|
</label>
|
||||||
|
<div>
|
||||||
|
<span> </span>
|
||||||
|
<Panel.InlineElements relation='inner'>
|
||||||
|
<Button variant='ghosted-white' onClick={() => handleTestOSCOutput(index)}>
|
||||||
|
Test
|
||||||
|
</Button>
|
||||||
|
<IconButton
|
||||||
|
aria-label='Delete'
|
||||||
|
variant='ghosted-destructive'
|
||||||
|
onClick={() => removeOutput(index)}
|
||||||
|
>
|
||||||
|
<IoTrash />
|
||||||
|
</IconButton>
|
||||||
|
</Panel.InlineElements>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
</div>
|
}
|
||||||
);
|
if (isHTTPOutput(output)) {
|
||||||
}
|
const rowErrors = errors.outputs?.[index] as
|
||||||
|
| {
|
||||||
|
url?: { message?: string };
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
return (
|
||||||
|
<div key={output.id} className={style.outputCard}>
|
||||||
|
<Tag>HTTP</Tag>
|
||||||
|
<div className={style.httpSection}>
|
||||||
|
<label>
|
||||||
|
Target URL
|
||||||
|
<TemplateInput
|
||||||
|
{...register(`outputs.${index}.url`, {
|
||||||
|
required: { value: true, message: 'Required field' },
|
||||||
|
pattern: {
|
||||||
|
value: startsWithHttp,
|
||||||
|
message: 'HTTP messages should target http:// or https://',
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
value={output.url}
|
||||||
|
fluid
|
||||||
|
placeholder='http://127.0.0.1/start/1'
|
||||||
|
/>
|
||||||
|
<Panel.Error>{rowErrors?.url?.message}</Panel.Error>
|
||||||
|
</label>
|
||||||
|
<div>
|
||||||
|
<span> </span>
|
||||||
|
<Panel.InlineElements relation='inner'>
|
||||||
|
<Button variant='ghosted-white' onClick={() => handleTestHTTPOutput(index)}>
|
||||||
|
Test
|
||||||
|
</Button>
|
||||||
|
<IconButton
|
||||||
|
aria-label='Delete'
|
||||||
|
variant='ghosted-destructive'
|
||||||
|
onClick={() => removeOutput(index)}
|
||||||
|
>
|
||||||
|
<IoTrash />
|
||||||
|
</IconButton>
|
||||||
|
</Panel.InlineElements>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (isOntimeAction(output)) {
|
if (isOntimeAction(output)) {
|
||||||
const rowErrors = errors.outputs?.[index] as
|
const rowErrors = errors.outputs?.[index] as
|
||||||
| {
|
| {
|
||||||
action?: { message?: string };
|
action?: { message?: string };
|
||||||
time?: { message?: string };
|
time?: { message?: string };
|
||||||
text?: { message?: string };
|
text?: { message?: string };
|
||||||
visible?: { message?: string };
|
visible?: { message?: string };
|
||||||
secondarySource?: { message?: string };
|
secondarySource?: { message?: string };
|
||||||
}
|
}
|
||||||
| undefined;
|
| undefined;
|
||||||
return (
|
return (
|
||||||
<div key={output.id} className={style.outputCard}>
|
<div key={output.id} className={style.outputCard}>
|
||||||
<Tag>Ontime action</Tag>
|
<Tag>Ontime action</Tag>
|
||||||
<OntimeActionForm
|
<OntimeActionForm
|
||||||
value={output.action}
|
value={output.action}
|
||||||
index={index}
|
index={index}
|
||||||
register={register}
|
register={register}
|
||||||
rowErrors={rowErrors}
|
rowErrors={rowErrors}
|
||||||
setValue={setValue}
|
setValue={setValue}
|
||||||
watch={watch}
|
watch={watch}
|
||||||
>
|
>
|
||||||
<span> </span>
|
<span> </span>
|
||||||
<Panel.InlineElements relation='inner'>
|
<Panel.InlineElements relation='inner'>
|
||||||
<Button variant='ghosted-white' onClick={() => handleTestOntimeAction(index)}>
|
<Button variant='ghosted-white' onClick={() => handleTestOntimeAction(index)}>
|
||||||
Test
|
Test
|
||||||
</Button>
|
</Button>
|
||||||
<IconButton aria-label='Delete' variant='ghosted-destructive' onClick={() => removeOutput(index)}>
|
<IconButton
|
||||||
<IoTrash />
|
aria-label='Delete'
|
||||||
</IconButton>
|
variant='ghosted-destructive'
|
||||||
</Panel.InlineElements>
|
onClick={() => removeOutput(index)}
|
||||||
</OntimeActionForm>
|
>
|
||||||
</div>
|
<IoTrash />
|
||||||
);
|
</IconButton>
|
||||||
}
|
</Panel.InlineElements>
|
||||||
|
</OntimeActionForm>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// there should be no other output types
|
return null;
|
||||||
return null;
|
})}
|
||||||
})}
|
<Panel.InlineElements relation='inner'>
|
||||||
<Panel.InlineElements relation='inner'>
|
<Button onClick={handleAddNewOSCOutput}>
|
||||||
<Button onClick={handleAddNewOSCOutput}>
|
OSC <IoAdd />
|
||||||
OSC <IoAdd />
|
</Button>
|
||||||
|
<Button onClick={handleAddNewHTTPOutput}>
|
||||||
|
HTTP <IoAdd />
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleAddnewOntimeAction}>
|
||||||
|
Ontime action <IoAdd />
|
||||||
|
</Button>
|
||||||
|
</Panel.InlineElements>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
footerElements={
|
||||||
|
<>
|
||||||
|
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}
|
||||||
|
<Button onClick={onClose}>Cancel</Button>
|
||||||
|
<Button variant='primary' type='submit' form={formId} disabled={!canSubmit} loading={isSubmitting}>
|
||||||
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleAddNewHTTPOutput}>
|
</>
|
||||||
HTTP <IoAdd />
|
}
|
||||||
</Button>
|
/>
|
||||||
<Button onClick={handleAddnewOntimeAction}>
|
|
||||||
Ontime action <IoAdd />
|
|
||||||
</Button>
|
|
||||||
</Panel.InlineElements>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Panel.InlineElements align='end'>
|
|
||||||
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}
|
|
||||||
<Button onClick={onClose}>Cancel</Button>
|
|
||||||
<Button variant='primary' type='submit' disabled={!canSubmit} loading={isSubmitting}>
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
</Panel.InlineElements>
|
|
||||||
</Panel.Indent>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,24 +19,27 @@ export default function AutomationPanel({ location }: PanelBaseProps) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Panel.Header>Automation</Panel.Header>
|
<Panel.Header>Automation</Panel.Header>
|
||||||
<Panel.Section>
|
<div ref={settingsRef}>
|
||||||
<Panel.Loader isLoading={isLoading} />
|
<AutomationSettingsForm
|
||||||
<div ref={settingsRef}>
|
enabledAutomations={data.enabledAutomations}
|
||||||
<AutomationSettingsForm
|
enabledOscIn={data.enabledOscIn}
|
||||||
enabledAutomations={data.enabledAutomations}
|
oscPortIn={data.oscPortIn}
|
||||||
enabledOscIn={data.enabledOscIn}
|
automationState={automationState}
|
||||||
oscPortIn={data.oscPortIn}
|
oscInputState={oscInputState}
|
||||||
automationState={automationState}
|
isLoading={isLoading}
|
||||||
oscInputState={oscInputState}
|
/>
|
||||||
/>
|
</div>
|
||||||
</div>
|
<div ref={automationsRef}>
|
||||||
<div ref={automationsRef}>
|
<AutomationsList automations={data.automations} enabledAutomations={automationState} isLoading={isLoading} />
|
||||||
<AutomationsList automations={data.automations} enabledAutomations={automationState} />
|
</div>
|
||||||
</div>
|
<div ref={triggersRef}>
|
||||||
<div ref={triggersRef}>
|
<TriggersList
|
||||||
<TriggersList triggers={data.triggers} automations={data.automations} enabledAutomations={automationState} />
|
triggers={data.triggers}
|
||||||
</div>
|
automations={data.automations}
|
||||||
</Panel.Section>
|
enabledAutomations={automationState}
|
||||||
|
isLoading={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+122
-117
@@ -21,6 +21,7 @@ interface AutomationSettingsProps {
|
|||||||
oscPortIn: number;
|
oscPortIn: number;
|
||||||
automationState?: boolean;
|
automationState?: boolean;
|
||||||
oscInputState?: boolean;
|
oscInputState?: boolean;
|
||||||
|
isLoading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AutomationSettingsForm({
|
export default function AutomationSettingsForm({
|
||||||
@@ -29,6 +30,7 @@ export default function AutomationSettingsForm({
|
|||||||
oscPortIn,
|
oscPortIn,
|
||||||
automationState,
|
automationState,
|
||||||
oscInputState,
|
oscInputState,
|
||||||
|
isLoading,
|
||||||
}: AutomationSettingsProps) {
|
}: AutomationSettingsProps) {
|
||||||
const {
|
const {
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
@@ -65,126 +67,129 @@ export default function AutomationSettingsForm({
|
|||||||
const oscInputEnabled = watch('enabledOscIn');
|
const oscInputEnabled = watch('enabledOscIn');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Panel.Card>
|
<Panel.Section>
|
||||||
<Panel.SubHeader>
|
<Panel.Card>
|
||||||
Automation settings
|
<Panel.Loader isLoading={Boolean(isLoading)} />
|
||||||
<Panel.InlineElements>
|
<Panel.SubHeader>
|
||||||
<Button variant='ghosted' onClick={onReset} disabled={!canSubmit}>
|
Automation settings
|
||||||
Revert to saved
|
<Panel.InlineElements>
|
||||||
</Button>
|
<Button variant='ghosted' onClick={onReset} disabled={!canSubmit}>
|
||||||
<Button
|
Revert to saved
|
||||||
variant='primary'
|
</Button>
|
||||||
type='submit'
|
<Button
|
||||||
form='automation-settings-form'
|
variant='primary'
|
||||||
disabled={!canSubmit}
|
type='submit'
|
||||||
loading={isSubmitting}
|
form='automation-settings-form'
|
||||||
>
|
disabled={!canSubmit}
|
||||||
Save
|
loading={isSubmitting}
|
||||||
</Button>
|
>
|
||||||
</Panel.InlineElements>
|
Save
|
||||||
</Panel.SubHeader>
|
</Button>
|
||||||
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}
|
</Panel.InlineElements>
|
||||||
|
</Panel.SubHeader>
|
||||||
|
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}
|
||||||
|
|
||||||
<Panel.Divider />
|
<Panel.Divider />
|
||||||
|
|
||||||
<Panel.Section>
|
<Panel.Section>
|
||||||
<Info>
|
<Info>
|
||||||
<span>Control Ontime and share its data with external systems in your workflow.</span>
|
<span>Control Ontime and share its data with external systems in your workflow.</span>
|
||||||
<span>- Automations allow Ontime to send its data on lifecycle triggers.</span>
|
<span>- Automations allow Ontime to send its data on lifecycle triggers.</span>
|
||||||
<span>- OSC Input tells Ontime to listen to messages on the specific port.</span>
|
<span>- OSC Input tells Ontime to listen to messages on the specific port.</span>
|
||||||
<ExternalLink href={oscApiDocsUrl}>See the docs</ExternalLink>
|
<ExternalLink href={oscApiDocsUrl}>See the docs</ExternalLink>
|
||||||
</Info>
|
</Info>
|
||||||
</Panel.Section>
|
</Panel.Section>
|
||||||
|
|
||||||
<Panel.Section
|
<Panel.Section
|
||||||
as='form'
|
as='form'
|
||||||
id='automation-settings-form'
|
id='automation-settings-form'
|
||||||
onSubmit={handleSubmit(onSubmit)}
|
onSubmit={handleSubmit(onSubmit)}
|
||||||
onKeyDown={(event) => preventEscape(event, onReset)}
|
onKeyDown={(event) => preventEscape(event, onReset)}
|
||||||
>
|
>
|
||||||
<Panel.Loader isLoading={false} />
|
<Panel.Title>Automation</Panel.Title>
|
||||||
|
<Panel.ListGroup>
|
||||||
|
<Panel.ListItem>
|
||||||
|
<Panel.Field
|
||||||
|
title={
|
||||||
|
<>
|
||||||
|
<span>Enable automations</span>
|
||||||
|
{automationState === false && <Tag variant='warning'>OFF</Tag>}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
description={
|
||||||
|
automationState === false
|
||||||
|
? 'Automations are OFF. Triggers stay configured, but Ontime will not send messages.'
|
||||||
|
: 'Allow Ontime to send messages on lifecycle triggers'
|
||||||
|
}
|
||||||
|
descriptionTone={automationState === false ? 'warning' : 'default'}
|
||||||
|
error={errors.enabledAutomations?.message}
|
||||||
|
/>
|
||||||
|
<Switch
|
||||||
|
size='large'
|
||||||
|
checked={automationsEnabled}
|
||||||
|
onCheckedChange={(value: boolean) =>
|
||||||
|
setValue('enabledAutomations', value, { shouldDirty: true, shouldValidate: true })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Panel.ListItem>
|
||||||
|
</Panel.ListGroup>
|
||||||
|
<Panel.Title>OSC Input</Panel.Title>
|
||||||
|
|
||||||
<Panel.Title>Automation</Panel.Title>
|
<Panel.ListGroup>
|
||||||
<Panel.ListGroup>
|
{isOntimeCloud && (
|
||||||
<Panel.ListItem>
|
<Info>For security reasons OSC integrations are not available in the cloud service.</Info>
|
||||||
<Panel.Field
|
)}
|
||||||
title={
|
<Panel.ListItem>
|
||||||
<>
|
<Panel.Field
|
||||||
<span>Enable automations</span>
|
title={
|
||||||
{automationState === false && <Tag variant='warning'>OFF</Tag>}
|
<>
|
||||||
</>
|
<span>OSC input</span>
|
||||||
}
|
{oscInputState === false && <Tag variant='warning'>OFF</Tag>}
|
||||||
description={
|
</>
|
||||||
automationState === false
|
}
|
||||||
? 'Automations are OFF. Triggers stay configured, but Ontime will not send messages.'
|
description={
|
||||||
: 'Allow Ontime to send messages on lifecycle triggers'
|
oscInputState === false
|
||||||
}
|
? 'OSC input is OFF. Ontime will not listen for incoming OSC control messages.'
|
||||||
descriptionTone={automationState === false ? 'warning' : 'default'}
|
: 'Allow control of Ontime through OSC'
|
||||||
error={errors.enabledAutomations?.message}
|
}
|
||||||
/>
|
descriptionTone={oscInputState === false ? 'warning' : 'default'}
|
||||||
<Switch
|
error={errors.enabledOscIn?.message}
|
||||||
size='large'
|
/>
|
||||||
checked={automationsEnabled}
|
<Switch
|
||||||
onCheckedChange={(value: boolean) =>
|
size='large'
|
||||||
setValue('enabledAutomations', value, { shouldDirty: true, shouldValidate: true })
|
checked={oscInputEnabled}
|
||||||
}
|
onCheckedChange={(value: boolean) =>
|
||||||
/>
|
setValue('enabledOscIn', value, { shouldDirty: true, shouldValidate: true })
|
||||||
</Panel.ListItem>
|
}
|
||||||
</Panel.ListGroup>
|
/>
|
||||||
<Panel.Title>OSC Input</Panel.Title>
|
</Panel.ListItem>
|
||||||
|
<Panel.ListItem>
|
||||||
<Panel.ListGroup>
|
<Panel.Field
|
||||||
{isOntimeCloud && <Info>For security reasons OSC integrations are not available in the cloud service.</Info>}
|
title='Listen on port'
|
||||||
<Panel.ListItem>
|
description='Port for incoming OSC. Default: 8888'
|
||||||
<Panel.Field
|
error={errors.oscPortIn?.message}
|
||||||
title={
|
/>
|
||||||
<>
|
<Input
|
||||||
<span>OSC input</span>
|
id='oscPortIn'
|
||||||
{oscInputState === false && <Tag variant='warning'>OFF</Tag>}
|
placeholder='8888'
|
||||||
</>
|
maxLength={5}
|
||||||
}
|
style={{ textAlign: 'right', width: '5rem' }}
|
||||||
description={
|
type='number'
|
||||||
oscInputState === false
|
fluid
|
||||||
? 'OSC input is OFF. Ontime will not listen for incoming OSC control messages.'
|
{...register('oscPortIn', {
|
||||||
: 'Allow control of Ontime through OSC'
|
required: { value: true, message: 'Required field' },
|
||||||
}
|
max: { value: 65535, message: 'Port must be within range 1024 - 65535' },
|
||||||
descriptionTone={oscInputState === false ? 'warning' : 'default'}
|
min: { value: 1024, message: 'Port must be within range 1024 - 65535' },
|
||||||
error={errors.enabledOscIn?.message}
|
pattern: {
|
||||||
/>
|
value: isOnlyNumbers,
|
||||||
<Switch
|
message: 'Value should be numeric',
|
||||||
size='large'
|
},
|
||||||
checked={oscInputEnabled}
|
})}
|
||||||
onCheckedChange={(value: boolean) =>
|
/>
|
||||||
setValue('enabledOscIn', value, { shouldDirty: true, shouldValidate: true })
|
</Panel.ListItem>
|
||||||
}
|
</Panel.ListGroup>
|
||||||
/>
|
</Panel.Section>
|
||||||
</Panel.ListItem>
|
</Panel.Card>
|
||||||
<Panel.ListItem>
|
</Panel.Section>
|
||||||
<Panel.Field
|
|
||||||
title='Listen on port'
|
|
||||||
description='Port for incoming OSC. Default: 8888'
|
|
||||||
error={errors.oscPortIn?.message}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
id='oscPortIn'
|
|
||||||
placeholder='8888'
|
|
||||||
maxLength={5}
|
|
||||||
style={{ textAlign: 'right', width: '5rem' }}
|
|
||||||
type='number'
|
|
||||||
fluid
|
|
||||||
{...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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,10 +22,10 @@ const automationPlaceholder: AutomationDTO = {
|
|||||||
interface AutomationsListProps {
|
interface AutomationsListProps {
|
||||||
automations: NormalisedAutomation;
|
automations: NormalisedAutomation;
|
||||||
enabledAutomations?: boolean;
|
enabledAutomations?: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AutomationsList(props: AutomationsListProps) {
|
export default function AutomationsList({ automations, enabledAutomations, isLoading }: AutomationsListProps) {
|
||||||
const { automations, enabledAutomations } = props;
|
|
||||||
const { refetch } = useAutomationSettings();
|
const { refetch } = useAutomationSettings();
|
||||||
const [automationFormData, setAutomationFormData] = useState<AutomationDTO | null>(null);
|
const [automationFormData, setAutomationFormData] = useState<AutomationDTO | null>(null);
|
||||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||||
@@ -44,89 +44,94 @@ export default function AutomationsList(props: AutomationsListProps) {
|
|||||||
const arrayAutomations = Object.keys(automations);
|
const arrayAutomations = Object.keys(automations);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Panel.Card>
|
<Panel.Section>
|
||||||
<Panel.SubHeader>
|
<Panel.Card>
|
||||||
Manage automations
|
{automationFormData !== null && (
|
||||||
<Button
|
<AutomationForm automation={automationFormData} onClose={() => setAutomationFormData(null)} />
|
||||||
type='submit'
|
)}
|
||||||
disabled={Boolean(automationFormData)}
|
<Panel.SubHeader>
|
||||||
onClick={() => setAutomationFormData(automationPlaceholder)}
|
Manage automations
|
||||||
>
|
<Button onClick={() => setAutomationFormData(automationPlaceholder)}>
|
||||||
New <IoAdd />
|
New <IoAdd />
|
||||||
</Button>
|
</Button>
|
||||||
</Panel.SubHeader>
|
</Panel.SubHeader>
|
||||||
|
|
||||||
<Panel.Divider />
|
<Panel.Divider />
|
||||||
|
|
||||||
{enabledAutomations === false && (
|
<Panel.Section>
|
||||||
<Info>
|
{enabledAutomations === false && (
|
||||||
Automations are disabled. You can still manage automation definitions here, but they will not run until
|
<Info>
|
||||||
enabled.
|
Automations are disabled. You can still manage automation definitions here, but they will not run until
|
||||||
</Info>
|
enabled.
|
||||||
)}
|
</Info>
|
||||||
|
|
||||||
{automationFormData !== null && (
|
|
||||||
<AutomationForm automation={automationFormData} onClose={() => setAutomationFormData(null)} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Panel.Table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th style={{ width: '45%' }}>Title</th>
|
|
||||||
<th style={{ width: '15%' }}>Trigger rule</th>
|
|
||||||
<th style={{ width: '15%' }}>Filters</th>
|
|
||||||
<th style={{ width: '15%' }}>Outputs</th>
|
|
||||||
<th />
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{arrayAutomations.length === 0 && (
|
|
||||||
<Panel.TableEmpty
|
|
||||||
handleClick={!automationFormData ? () => setAutomationFormData(automationPlaceholder) : undefined}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
{arrayAutomations.map((automationId) => {
|
|
||||||
if (!Object.hasOwn(automations, automationId)) {
|
<Panel.Table>
|
||||||
return null;
|
<thead>
|
||||||
}
|
<tr>
|
||||||
return (
|
<th style={{ width: '45%' }}>Title</th>
|
||||||
<Fragment key={automationId}>
|
<th style={{ width: '15%' }}>Trigger rule</th>
|
||||||
|
<th style={{ width: '15%' }}>Filters</th>
|
||||||
|
<th style={{ width: '15%' }}>Outputs</th>
|
||||||
|
<th />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{!isLoading && arrayAutomations.length === 0 && (
|
||||||
|
<Panel.TableEmpty
|
||||||
|
title='No automations yet'
|
||||||
|
description='An automation sends OSC or HTTP messages, or runs an Ontime action, whenever a trigger fires.'
|
||||||
|
action={
|
||||||
|
<Button variant='primary' onClick={() => setAutomationFormData(automationPlaceholder)}>
|
||||||
|
Create automation <IoAdd />
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{arrayAutomations.map((automationId) => {
|
||||||
|
if (!Object.hasOwn(automations, automationId)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Fragment key={automationId}>
|
||||||
|
<tr>
|
||||||
|
<td>{automations[automationId].title}</td>
|
||||||
|
<td>
|
||||||
|
<Tag>{automations[automationId].filterRule}</Tag>
|
||||||
|
</td>
|
||||||
|
<td>{automations[automationId].filters.length}</td>
|
||||||
|
<td>{automations[automationId].outputs.length}</td>
|
||||||
|
<Panel.InlineElements align='end' relation='inner' as='td'>
|
||||||
|
<IconButton
|
||||||
|
variant='ghosted-white'
|
||||||
|
aria-label='Edit entry'
|
||||||
|
onClick={() => setAutomationFormData(automations[automationId])}
|
||||||
|
>
|
||||||
|
<IoPencil />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
variant='ghosted-destructive'
|
||||||
|
aria-label='Delete entry'
|
||||||
|
onClick={() => handleDelete(automationId)}
|
||||||
|
>
|
||||||
|
<IoTrash />
|
||||||
|
</IconButton>
|
||||||
|
</Panel.InlineElements>
|
||||||
|
</tr>
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{deleteError && (
|
||||||
<tr>
|
<tr>
|
||||||
<td>{automations[automationId].title}</td>
|
<td colSpan={5}>
|
||||||
<td>
|
<Panel.Error>{deleteError}</Panel.Error>
|
||||||
<Tag>{automations[automationId].filterRule}</Tag>
|
|
||||||
</td>
|
</td>
|
||||||
<td>{automations[automationId].filters.length}</td>
|
|
||||||
<td>{automations[automationId].outputs.length}</td>
|
|
||||||
<Panel.InlineElements align='end' relation='inner' as='td'>
|
|
||||||
<IconButton
|
|
||||||
variant='ghosted-white'
|
|
||||||
aria-label='Edit entry'
|
|
||||||
onClick={() => setAutomationFormData(automations[automationId])}
|
|
||||||
>
|
|
||||||
<IoPencil />
|
|
||||||
</IconButton>
|
|
||||||
<IconButton
|
|
||||||
variant='ghosted-destructive'
|
|
||||||
aria-label='Delete entry'
|
|
||||||
onClick={() => handleDelete(automationId)}
|
|
||||||
>
|
|
||||||
<IoTrash />
|
|
||||||
</IconButton>
|
|
||||||
</Panel.InlineElements>
|
|
||||||
</tr>
|
</tr>
|
||||||
</Fragment>
|
)}
|
||||||
);
|
</tbody>
|
||||||
})}
|
</Panel.Table>
|
||||||
{deleteError && (
|
</Panel.Section>
|
||||||
<tr>
|
</Panel.Card>
|
||||||
<td colSpan={5}>
|
</Panel.Section>
|
||||||
<Panel.Error>{deleteError}</Panel.Error>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</Panel.Table>
|
|
||||||
</Panel.Card>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { NormalisedAutomation, TimerLifeCycle, TriggerDTO } from 'ontime-types';
|
import { NormalisedAutomation, TimerLifeCycle, Trigger, TriggerDTO } from 'ontime-types';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
|
|
||||||
@@ -6,30 +6,21 @@ import { addTrigger, editTrigger } from '../../../../common/api/automation';
|
|||||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||||
import Button from '../../../../common/components/buttons/Button';
|
import Button from '../../../../common/components/buttons/Button';
|
||||||
import Input from '../../../../common/components/input/input/Input';
|
import Input from '../../../../common/components/input/input/Input';
|
||||||
|
import Modal from '../../../../common/components/modal/Modal';
|
||||||
import Select from '../../../../common/components/select/Select';
|
import Select from '../../../../common/components/select/Select';
|
||||||
import { preventEscape } from '../../../../common/utils/keyEvent';
|
|
||||||
import * as Panel from '../../panel-utils/PanelUtils';
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
import { cycles } from './automationUtils';
|
import { cycles } from './automationUtils';
|
||||||
|
|
||||||
|
const formId = 'trigger-form';
|
||||||
|
|
||||||
interface TriggerFormProps {
|
interface TriggerFormProps {
|
||||||
automations: NormalisedAutomation;
|
automations: NormalisedAutomation;
|
||||||
initialId?: string;
|
trigger: Trigger | null;
|
||||||
initialTitle?: string;
|
|
||||||
initialAutomationId?: string;
|
|
||||||
initialTrigger?: TimerLifeCycle;
|
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
postSubmit: () => void;
|
postSubmit: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TriggerForm({
|
export default function TriggerForm({ automations, trigger, onCancel, postSubmit }: TriggerFormProps) {
|
||||||
automations,
|
|
||||||
initialId,
|
|
||||||
initialTitle,
|
|
||||||
initialAutomationId,
|
|
||||||
initialTrigger,
|
|
||||||
onCancel,
|
|
||||||
postSubmit,
|
|
||||||
}: TriggerFormProps) {
|
|
||||||
const {
|
const {
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
register,
|
register,
|
||||||
@@ -40,9 +31,9 @@ export default function TriggerForm({
|
|||||||
formState: { errors, isSubmitting, isValid, isDirty },
|
formState: { errors, isSubmitting, isValid, isDirty },
|
||||||
} = useForm<TriggerDTO>({
|
} = useForm<TriggerDTO>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
title: initialTitle,
|
title: trigger?.title,
|
||||||
trigger: initialTrigger ?? (cycles[0].value as TimerLifeCycle | undefined),
|
trigger: trigger?.trigger ?? (cycles[0].value as TimerLifeCycle | undefined),
|
||||||
automationId: initialAutomationId ?? automations?.[Object.keys(automations)[0]]?.id,
|
automationId: trigger?.automationId ?? automations?.[Object.keys(automations)[0]]?.id,
|
||||||
},
|
},
|
||||||
resetOptions: {
|
resetOptions: {
|
||||||
keepDirtyValues: true,
|
keepDirtyValues: true,
|
||||||
@@ -55,10 +46,9 @@ export default function TriggerForm({
|
|||||||
}, [setFocus]);
|
}, [setFocus]);
|
||||||
|
|
||||||
const onSubmit = async (values: TriggerDTO) => {
|
const onSubmit = async (values: TriggerDTO) => {
|
||||||
// if we were passed an ID we are editing a Trigger
|
if (trigger) {
|
||||||
if (initialId) {
|
|
||||||
try {
|
try {
|
||||||
await editTrigger(initialId, { id: initialId, ...values });
|
await editTrigger(trigger.id, { id: trigger.id, ...values });
|
||||||
postSubmit();
|
postSubmit();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setError('root', { message: `Failed to save changes to trigger ${maybeAxiosError(error)}` });
|
setError('root', { message: `Failed to save changes to trigger ${maybeAxiosError(error)}` });
|
||||||
@@ -85,56 +75,63 @@ export default function TriggerForm({
|
|||||||
const canSubmit = isDirty && isValid;
|
const canSubmit = isDirty && isValid;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Panel.Indent
|
<Modal
|
||||||
as='form'
|
isOpen
|
||||||
name='trigger-form'
|
onClose={onCancel}
|
||||||
onSubmit={handleSubmit(onSubmit)}
|
showBackdrop
|
||||||
onKeyDown={(event) => preventEscape(event, onCancel)}
|
showCloseButton
|
||||||
>
|
size='compact'
|
||||||
<Panel.SubHeader>{initialId ? 'Edit trigger' : 'Create trigger'}</Panel.SubHeader>
|
title={trigger ? 'Edit trigger' : 'Create trigger'}
|
||||||
<label>
|
bodyElements={
|
||||||
Title
|
<form id={formId} onSubmit={handleSubmit(onSubmit)}>
|
||||||
<Input
|
<label>
|
||||||
{...register('title', { required: { value: true, message: 'Required field' } })}
|
Title
|
||||||
fluid
|
<Input
|
||||||
defaultValue={initialTitle}
|
{...register('title', { required: { value: true, message: 'Required field' } })}
|
||||||
/>
|
fluid
|
||||||
<Panel.Error>{errors.title?.message}</Panel.Error>
|
defaultValue={trigger?.title}
|
||||||
</label>
|
/>
|
||||||
<label>
|
<Panel.Error>{errors.title?.message}</Panel.Error>
|
||||||
Lifecycle trigger
|
</label>
|
||||||
<Select
|
<label>
|
||||||
value={watch('trigger')}
|
Lifecycle trigger
|
||||||
onValueChange={(value) => {
|
<Select
|
||||||
if (value === null) return;
|
value={watch('trigger')}
|
||||||
setValue('trigger', value as TimerLifeCycle, { shouldDirty: true });
|
onValueChange={(value) => {
|
||||||
}}
|
if (value === null) return;
|
||||||
options={cycles.map((cycle) => ({ value: cycle.value, label: cycle.label }))}
|
setValue('trigger', value as TimerLifeCycle, { shouldDirty: true });
|
||||||
aria-label='Lifecycle trigger'
|
}}
|
||||||
/>
|
options={cycles.map((cycle) => ({ value: cycle.value, label: cycle.label }))}
|
||||||
<Panel.Error>{errors.trigger?.message}</Panel.Error>
|
aria-label='Lifecycle trigger'
|
||||||
</label>
|
/>
|
||||||
<label>
|
<Panel.Error>{errors.trigger?.message}</Panel.Error>
|
||||||
Automation title
|
</label>
|
||||||
<Select
|
<label>
|
||||||
value={watch('automationId')}
|
Automation title
|
||||||
onValueChange={(value: string | null) => {
|
<Select
|
||||||
if (value === null) return;
|
value={watch('automationId')}
|
||||||
setValue('automationId', value, { shouldDirty: true });
|
onValueChange={(value: string | null) => {
|
||||||
}}
|
if (value === null) return;
|
||||||
options={automationSelect}
|
setValue('automationId', value, { shouldDirty: true });
|
||||||
aria-label='Automation title'
|
}}
|
||||||
/>
|
options={automationSelect}
|
||||||
<Panel.Error>{errors.automationId?.message}</Panel.Error>
|
aria-label='Automation title'
|
||||||
</label>
|
/>
|
||||||
<Panel.InlineElements align='end'>
|
<Panel.Error>{errors.automationId?.message}</Panel.Error>
|
||||||
<Button disabled={isSubmitting} onClick={onCancel}>
|
</label>
|
||||||
Cancel
|
</form>
|
||||||
</Button>
|
}
|
||||||
<Button type='submit' variant='primary' disabled={!canSubmit} loading={isSubmitting}>
|
footerElements={
|
||||||
Save
|
<>
|
||||||
</Button>
|
{errors.root && <Panel.Error>{errors.root.message}</Panel.Error>}
|
||||||
</Panel.InlineElements>
|
<Button disabled={isSubmitting} onClick={onCancel}>
|
||||||
</Panel.Indent>
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type='submit' form={formId} variant='primary' disabled={!canSubmit} loading={isSubmitting}>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,23 +9,33 @@ import Info from '../../../../common/components/info/Info';
|
|||||||
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
|
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
|
||||||
import * as Panel from '../../panel-utils/PanelUtils';
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
import { checkDuplicates } from './automationUtils';
|
import { checkDuplicates } from './automationUtils';
|
||||||
import AutomationForm from './TriggerForm';
|
import TriggerForm from './TriggerForm';
|
||||||
import TriggersListItem from './TriggersListItem';
|
import TriggersListItem from './TriggersListItem';
|
||||||
|
|
||||||
|
type FormState = {
|
||||||
|
isOpen: boolean;
|
||||||
|
trigger?: Trigger;
|
||||||
|
};
|
||||||
|
|
||||||
interface TriggersListProps {
|
interface TriggersListProps {
|
||||||
triggers: Trigger[];
|
triggers: Trigger[];
|
||||||
automations: NormalisedAutomation;
|
automations: NormalisedAutomation;
|
||||||
enabledAutomations?: boolean;
|
enabledAutomations?: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TriggersList(props: TriggersListProps) {
|
export default function TriggersList({ triggers, automations, enabledAutomations, isLoading }: TriggersListProps) {
|
||||||
const { triggers, automations, enabledAutomations } = props;
|
const [formState, setFormState] = useState<FormState>({ isOpen: false, trigger: undefined });
|
||||||
const [showForm, setShowForm] = useState(false);
|
|
||||||
const { refetch } = useAutomationSettings();
|
const { refetch } = useAutomationSettings();
|
||||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const openNewForm = () => setFormState({ isOpen: true });
|
||||||
|
const openEditForm = (trigger: Trigger) => setFormState({ isOpen: true, trigger });
|
||||||
|
const closeForm = () => setFormState({ isOpen: false, trigger: undefined });
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
const handleDelete = async (id: string) => {
|
||||||
try {
|
try {
|
||||||
|
setDeleteError(null);
|
||||||
await deleteTrigger(id);
|
await deleteTrigger(id);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setDeleteError(maybeAxiosError(error));
|
setDeleteError(maybeAxiosError(error));
|
||||||
@@ -35,7 +45,7 @@ export default function TriggersList(props: TriggersListProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const postSubmit = () => {
|
const postSubmit = () => {
|
||||||
setShowForm(false);
|
closeForm();
|
||||||
refetch();
|
refetch();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -45,70 +55,86 @@ export default function TriggersList(props: TriggersListProps) {
|
|||||||
const canAdd = Object.keys(automations).length > 0;
|
const canAdd = Object.keys(automations).length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Panel.Card>
|
<Panel.Section>
|
||||||
<Panel.SubHeader>
|
<Panel.Card>
|
||||||
Manage triggers
|
{formState.isOpen && (
|
||||||
<Button type='submit' form='trigger-form' disabled={!canAdd} loading={false} onClick={() => setShowForm(true)}>
|
<TriggerForm
|
||||||
New <IoAdd />
|
automations={automations}
|
||||||
</Button>
|
trigger={formState.trigger ?? null}
|
||||||
</Panel.SubHeader>
|
onCancel={closeForm}
|
||||||
<Panel.Divider />
|
postSubmit={postSubmit}
|
||||||
<Panel.Section>
|
/>
|
||||||
{enabledAutomations === false && (
|
|
||||||
<Info>
|
|
||||||
Automations are disabled. You can still manage triggers here, but they will not run until enabled.
|
|
||||||
</Info>
|
|
||||||
)}
|
)}
|
||||||
{duplicates && (
|
<Panel.SubHeader>
|
||||||
<Panel.Error>
|
Manage triggers
|
||||||
You have created multiple links between the same trigger and automation which can performance issues.
|
<Button disabled={!canAdd} onClick={openNewForm}>
|
||||||
</Panel.Error>
|
New <IoAdd />
|
||||||
)}
|
</Button>
|
||||||
{showForm && (
|
</Panel.SubHeader>
|
||||||
<AutomationForm automations={automations} onCancel={() => setShowForm(false)} postSubmit={postSubmit} />
|
<Panel.Divider />
|
||||||
)}
|
<Panel.Section>
|
||||||
<Panel.Table>
|
{enabledAutomations === false && (
|
||||||
<thead>
|
<Info>
|
||||||
<tr>
|
Automations are disabled. You can still manage triggers here, but they will not run until enabled.
|
||||||
<th style={{ width: '35%' }}>Title</th>
|
</Info>
|
||||||
<th style={{ width: '25%' }}>Lifecycle trigger</th>
|
)}
|
||||||
<th style={{ width: '25%' }}>Automation</th>
|
{duplicates && (
|
||||||
<th />
|
<Panel.Error>
|
||||||
</tr>
|
You have created multiple links between the same trigger and automation which can cause performance
|
||||||
</thead>
|
issues.
|
||||||
<tbody>
|
</Panel.Error>
|
||||||
{!showForm && triggers.length === 0 && (
|
)}
|
||||||
<Panel.TableEmpty
|
<Panel.Table>
|
||||||
label='Create a trigger to run an automation'
|
<thead>
|
||||||
handleClick={canAdd ? () => setShowForm(true) : undefined}
|
<tr>
|
||||||
/>
|
<th style={{ width: '35%' }}>Title</th>
|
||||||
)}
|
<th style={{ width: '25%' }}>Lifecycle trigger</th>
|
||||||
{triggers.map((trigger, index) => {
|
<th style={{ width: '25%' }}>Automation</th>
|
||||||
return (
|
<th />
|
||||||
<Fragment key={trigger.id}>
|
</tr>
|
||||||
<TriggersListItem
|
</thead>
|
||||||
automations={automations}
|
<tbody>
|
||||||
id={trigger.id}
|
{!isLoading && triggers.length === 0 && (
|
||||||
title={trigger.title}
|
<Panel.TableEmpty
|
||||||
trigger={trigger.trigger}
|
title='No triggers yet'
|
||||||
automationId={trigger.automationId}
|
description={
|
||||||
duplicate={duplicates?.includes(index)}
|
canAdd
|
||||||
handleDelete={() => handleDelete(trigger.id)}
|
? 'Triggers run an automation at a given point of the timer lifecycle, like when an event starts or finishes.'
|
||||||
postSubmit={postSubmit}
|
: 'Create an automation first, then add a trigger to decide when it should run.'
|
||||||
/>
|
}
|
||||||
{deleteError && (
|
action={
|
||||||
<tr>
|
canAdd && (
|
||||||
<td colSpan={5}>
|
<Button variant='primary' onClick={openNewForm}>
|
||||||
<Panel.Error>{deleteError}</Panel.Error>
|
Create trigger <IoAdd />
|
||||||
</td>
|
</Button>
|
||||||
</tr>
|
)
|
||||||
)}
|
}
|
||||||
</Fragment>
|
/>
|
||||||
);
|
)}
|
||||||
})}
|
{triggers.map((trigger, index) => {
|
||||||
</tbody>
|
return (
|
||||||
</Panel.Table>
|
<Fragment key={trigger.id}>
|
||||||
</Panel.Section>
|
<TriggersListItem
|
||||||
</Panel.Card>
|
automations={automations}
|
||||||
|
trigger={trigger}
|
||||||
|
duplicate={duplicates?.includes(index)}
|
||||||
|
handleEdit={() => openEditForm(trigger)}
|
||||||
|
handleDelete={() => handleDelete(trigger.id)}
|
||||||
|
/>
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{deleteError && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5}>
|
||||||
|
<Panel.Error>{deleteError}</Panel.Error>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</Panel.Table>
|
||||||
|
</Panel.Section>
|
||||||
|
</Panel.Card>
|
||||||
|
</Panel.Section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,48 +1,21 @@
|
|||||||
import { NormalisedAutomation, TimerLifeCycle } from 'ontime-types';
|
import { NormalisedAutomation, Trigger } from 'ontime-types';
|
||||||
import { useState } from 'react';
|
|
||||||
import { IoPencil, IoTrash, IoWarningOutline } from 'react-icons/io5';
|
import { IoPencil, IoTrash, IoWarningOutline } from 'react-icons/io5';
|
||||||
|
|
||||||
import IconButton from '../../../../common/components/buttons/IconButton';
|
import IconButton from '../../../../common/components/buttons/IconButton';
|
||||||
import Tag from '../../../../common/components/tag/Tag';
|
import Tag from '../../../../common/components/tag/Tag';
|
||||||
import * as Panel from '../../panel-utils/PanelUtils';
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
import { cycles } from './automationUtils';
|
import { cycles } from './automationUtils';
|
||||||
import AutomationForm from './TriggerForm';
|
|
||||||
|
|
||||||
interface TriggersListItemProps {
|
interface TriggersListItemProps {
|
||||||
automations: NormalisedAutomation;
|
automations: NormalisedAutomation;
|
||||||
id: string;
|
trigger: Trigger;
|
||||||
title: string;
|
|
||||||
trigger: TimerLifeCycle;
|
|
||||||
automationId: string;
|
|
||||||
duplicate?: boolean;
|
duplicate?: boolean;
|
||||||
|
handleEdit: () => void;
|
||||||
handleDelete: () => void;
|
handleDelete: () => void;
|
||||||
postSubmit: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TriggersListItem(props: TriggersListItemProps) {
|
export default function TriggersListItem(props: TriggersListItemProps) {
|
||||||
const { automations, id, title, trigger, automationId, duplicate, handleDelete, postSubmit } = props;
|
const { automations, trigger, duplicate, handleEdit, handleDelete } = props;
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
|
||||||
|
|
||||||
if (isEditing) {
|
|
||||||
return (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={99}>
|
|
||||||
<AutomationForm
|
|
||||||
automations={automations}
|
|
||||||
initialId={id}
|
|
||||||
initialTitle={title}
|
|
||||||
initialTrigger={trigger}
|
|
||||||
initialAutomationId={automationId}
|
|
||||||
onCancel={() => setIsEditing(false)}
|
|
||||||
postSubmit={() => {
|
|
||||||
setIsEditing(false);
|
|
||||||
postSubmit();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr data-warn={duplicate}>
|
<tr data-warn={duplicate}>
|
||||||
@@ -52,16 +25,16 @@ export default function TriggersListItem(props: TriggersListItemProps) {
|
|||||||
color='#FFBC56' // $orange-500
|
color='#FFBC56' // $orange-500
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{title}
|
{trigger.title}
|
||||||
</Panel.InlineElements>
|
</Panel.InlineElements>
|
||||||
<td>
|
<td>
|
||||||
<Tag>{cycles.find((cycle) => cycle.value === trigger)?.label}</Tag>
|
<Tag>{cycles.find((cycle) => cycle.value === trigger.trigger)?.label}</Tag>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<Tag>{automations?.[automationId]?.title}</Tag>
|
<Tag>{automations?.[trigger.automationId]?.title}</Tag>
|
||||||
</td>
|
</td>
|
||||||
<Panel.InlineElements align='end' relation='inner' as='td'>
|
<Panel.InlineElements align='end' relation='inner' as='td'>
|
||||||
<IconButton variant='ghosted-white' aria-label='Edit entry' onClick={() => setIsEditing(true)}>
|
<IconButton variant='ghosted-white' aria-label='Edit entry' onClick={handleEdit}>
|
||||||
<IoPencil />
|
<IoPencil />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<IconButton variant='ghosted-destructive' aria-label='Delete entry' onClick={handleDelete}>
|
<IconButton variant='ghosted-destructive' aria-label='Delete entry' onClick={handleDelete}>
|
||||||
|
|||||||
@@ -66,7 +66,10 @@ export default function ReportSettings() {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{combinedReport.length === 0 && (
|
{combinedReport.length === 0 && (
|
||||||
<Panel.TableEmpty label='Reports are generated when running through the show.' />
|
<Panel.TableEmpty
|
||||||
|
title='No report data yet'
|
||||||
|
description='Reports are generated as you run through the show, comparing scheduled times against what actually happened.'
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{combinedReport.map((entry) => {
|
{combinedReport.map((entry) => {
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export default function URLPresets() {
|
|||||||
return (
|
return (
|
||||||
<Panel.Section>
|
<Panel.Section>
|
||||||
<Panel.Card>
|
<Panel.Card>
|
||||||
|
{formState.isOpen && <URLPresetForm urlPreset={formState.preset} onClose={closeForm} />}
|
||||||
<Panel.SubHeader>
|
<Panel.SubHeader>
|
||||||
URL presets
|
URL presets
|
||||||
<Button onClick={openNewForm}>
|
<Button onClick={openNewForm}>
|
||||||
@@ -63,7 +64,6 @@ export default function URLPresets() {
|
|||||||
</Panel.Section>
|
</Panel.Section>
|
||||||
<Panel.Section>
|
<Panel.Section>
|
||||||
<Panel.Loader isLoading={status === 'pending'} />
|
<Panel.Loader isLoading={status === 'pending'} />
|
||||||
{formState.isOpen && <URLPresetForm urlPreset={formState.preset} onClose={closeForm} />}
|
|
||||||
{actionError && <Panel.Error>{actionError}</Panel.Error>}
|
{actionError && <Panel.Error>{actionError}</Panel.Error>}
|
||||||
<Panel.Table>
|
<Panel.Table>
|
||||||
<thead>
|
<thead>
|
||||||
@@ -76,7 +76,17 @@ export default function URLPresets() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{data.length === 0 && <Panel.TableEmpty handleClick={openNewForm} />}
|
{data.length === 0 && (
|
||||||
|
<Panel.TableEmpty
|
||||||
|
title='No URL presets yet'
|
||||||
|
description='A preset gives one of your views a short, memorable address, with all of its options baked in.'
|
||||||
|
action={
|
||||||
|
<Button variant='primary' onClick={openNewForm}>
|
||||||
|
Create preset <IoAdd />
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{data.map((preset, index) => {
|
{data.map((preset, index) => {
|
||||||
const isCuesheet = preset.target === OntimeView.Cuesheet;
|
const isCuesheet = preset.target === OntimeView.Cuesheet;
|
||||||
return (
|
return (
|
||||||
|
|||||||
+86
-78
@@ -6,9 +6,9 @@ import { maybeAxiosError, unwrapError } from '../../../../../common/api/utils';
|
|||||||
import Button from '../../../../../common/components/buttons/Button';
|
import Button from '../../../../../common/components/buttons/Button';
|
||||||
import Input from '../../../../../common/components/input/input/Input';
|
import Input from '../../../../../common/components/input/input/Input';
|
||||||
import Textarea from '../../../../../common/components/input/textarea/Textarea';
|
import Textarea from '../../../../../common/components/input/textarea/Textarea';
|
||||||
|
import Modal from '../../../../../common/components/modal/Modal';
|
||||||
import Select, { SelectOption } from '../../../../../common/components/select/Select';
|
import Select, { SelectOption } from '../../../../../common/components/select/Select';
|
||||||
import { useUpdateUrlPreset } from '../../../../../common/hooks-query/useUrlPresets';
|
import { useUpdateUrlPreset } from '../../../../../common/hooks-query/useUrlPresets';
|
||||||
import { preventEscape } from '../../../../../common/utils/keyEvent';
|
|
||||||
import { isUrlSafe } from '../../../../../common/utils/regex';
|
import { isUrlSafe } from '../../../../../common/utils/regex';
|
||||||
import { enDash } from '../../../../../common/utils/styleUtils';
|
import { enDash } from '../../../../../common/utils/styleUtils';
|
||||||
import { generateUrlPresetOptions } from '../../../../../common/utils/urlPresets';
|
import { generateUrlPresetOptions } from '../../../../../common/utils/urlPresets';
|
||||||
@@ -28,6 +28,8 @@ const targetOptions: SelectOption<OntimeViewPresettable>[] = [
|
|||||||
{ value: OntimeView.ProjectInfo, label: 'Project Info' },
|
{ value: OntimeView.ProjectInfo, label: 'Project Info' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const formId = 'url-preset-form';
|
||||||
|
|
||||||
const defaultValues: URLPreset = {
|
const defaultValues: URLPreset = {
|
||||||
alias: '',
|
alias: '',
|
||||||
target: OntimeView.Timer,
|
target: OntimeView.Timer,
|
||||||
@@ -134,95 +136,101 @@ export default function URLPresetForm({ urlPreset, onClose }: URLPresetFormProps
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Panel.Indent
|
<Modal
|
||||||
as='form'
|
isOpen
|
||||||
onSubmit={handleSubmit(setupSubmit)}
|
onClose={onClose}
|
||||||
onKeyDown={(event) => preventEscape(event, onClose)}
|
showBackdrop
|
||||||
className={style.column}
|
showCloseButton
|
||||||
>
|
size='compact'
|
||||||
<input hidden name='enabled' value='true' />
|
title={urlPreset ? 'Edit URL preset' : 'Create URL preset'}
|
||||||
|
bodyElements={
|
||||||
|
<form id={formId} onSubmit={handleSubmit(setupSubmit)} className={style.column}>
|
||||||
|
<input hidden name='enabled' value='true' />
|
||||||
|
|
||||||
<div>1. Enter URL and let Ontime generate the preset options</div>
|
<div>1. Enter URL and let Ontime generate the preset options</div>
|
||||||
<Panel.InlineElements>
|
|
||||||
<div>
|
|
||||||
<Panel.Description>Alias</Panel.Description>
|
|
||||||
<Input
|
|
||||||
{...register('alias', {
|
|
||||||
required: 'Alias is required',
|
|
||||||
pattern: {
|
|
||||||
value: isUrlSafe,
|
|
||||||
message: 'Field can only contain URL safe characters (a-z, 0-9, _ and -)',
|
|
||||||
},
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className={style.expand}>
|
|
||||||
<Panel.Description>Generate options (paste URL to generate options)</Panel.Description>
|
|
||||||
<Panel.InlineElements>
|
<Panel.InlineElements>
|
||||||
<Input placeholder='Paste URL' fluid ref={urlRef} disabled={isEditingCuesheet} />
|
<div>
|
||||||
<Button onClick={generateOptions} disabled={isEditingCuesheet}>
|
<Panel.Description>Alias</Panel.Description>
|
||||||
Generate
|
<Input
|
||||||
</Button>
|
{...register('alias', {
|
||||||
|
required: 'Alias is required',
|
||||||
|
pattern: {
|
||||||
|
value: isUrlSafe,
|
||||||
|
message: 'Field can only contain URL safe characters (a-z, 0-9, _ and -)',
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={style.expand}>
|
||||||
|
<Panel.Description>Generate options (paste URL to generate options)</Panel.Description>
|
||||||
|
<Panel.InlineElements>
|
||||||
|
<Input placeholder='Paste URL' fluid ref={urlRef} disabled={isEditingCuesheet} />
|
||||||
|
<Button onClick={generateOptions} disabled={isEditingCuesheet}>
|
||||||
|
Generate
|
||||||
|
</Button>
|
||||||
|
</Panel.InlineElements>
|
||||||
|
</div>
|
||||||
</Panel.InlineElements>
|
</Panel.InlineElements>
|
||||||
</div>
|
{errors.alias?.message && <Panel.Error>{errors.alias.message}</Panel.Error>}
|
||||||
</Panel.InlineElements>
|
{!isEditingCuesheet && (
|
||||||
{errors.alias?.message && <Panel.Error>{errors.alias.message}</Panel.Error>}
|
<>
|
||||||
{!isEditingCuesheet && (
|
<div>
|
||||||
<>
|
{enDash} or {enDash}
|
||||||
<div>
|
</div>
|
||||||
{enDash} or {enDash}
|
<div>2. Choose a view and its parameters</div>
|
||||||
</div>
|
<div>
|
||||||
<div>2. Choose a view and its parameters</div>
|
<Panel.Description>Target</Panel.Description>
|
||||||
<div>
|
<Select
|
||||||
<Panel.Description>Target</Panel.Description>
|
options={targetOptions}
|
||||||
<Select
|
{...register('target', { required: 'Target is required' })}
|
||||||
options={targetOptions}
|
value={watch('target')}
|
||||||
{...register('target', { required: 'Target is required' })}
|
onValueChange={(value: OntimeViewPresettable | null) => {
|
||||||
value={watch('target')}
|
if (value === null) return;
|
||||||
onValueChange={(value: OntimeViewPresettable | null) => {
|
setValue('target', value, { shouldDirty: true });
|
||||||
if (value === null) return;
|
}}
|
||||||
setValue('target', value, { shouldDirty: true });
|
/>
|
||||||
}}
|
</div>
|
||||||
/>
|
<div>
|
||||||
</div>
|
<Panel.Description>Parameters</Panel.Description>
|
||||||
<div>
|
<Textarea
|
||||||
<Panel.Description>Parameters</Panel.Description>
|
fluid
|
||||||
<Textarea
|
rows={3}
|
||||||
fluid
|
{...register('search', {
|
||||||
rows={3}
|
validate: validateParams,
|
||||||
{...register('search', {
|
})}
|
||||||
validate: validateParams,
|
/>
|
||||||
})}
|
<Panel.Error>{errors.search?.message}</Panel.Error>
|
||||||
/>
|
</div>
|
||||||
<Panel.Error>{errors.search?.message}</Panel.Error>
|
</>
|
||||||
</div>
|
)}
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isCuesheet && (
|
{isCuesheet && (
|
||||||
<div>
|
<div>
|
||||||
<Panel.Description>Permissions</Panel.Description>
|
<Panel.Description>Permissions</Panel.Description>
|
||||||
<CuesheetLinkOptions
|
<CuesheetLinkOptions
|
||||||
initialRead={initialPermissions.current.read}
|
initialRead={initialPermissions.current.read}
|
||||||
initialWrite={initialPermissions.current.write}
|
initialWrite={initialPermissions.current.write}
|
||||||
onChange={setCuesheetPermissions}
|
onChange={setCuesheetPermissions}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div>
|
</form>
|
||||||
<Panel.Error>{errors.root?.message}</Panel.Error>
|
}
|
||||||
<Panel.InlineElements align='end'>
|
footerElements={
|
||||||
|
<>
|
||||||
|
<Panel.Error>{errors.root?.message}</Panel.Error>
|
||||||
<Button onClick={onClose}>Cancel</Button>
|
<Button onClick={onClose}>Cancel</Button>
|
||||||
<Button
|
<Button
|
||||||
variant='primary'
|
variant='primary'
|
||||||
type='submit'
|
type='submit'
|
||||||
|
form={formId}
|
||||||
disabled={!isValid || (!isDirty && !permissionsDirty) || noReadAccess}
|
disabled={!isValid || (!isDirty && !permissionsDirty) || noReadAccess}
|
||||||
loading={isSubmitting || isMutating}
|
loading={isSubmitting || isMutating}
|
||||||
>
|
>
|
||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
</Panel.InlineElements>
|
</>
|
||||||
</div>
|
}
|
||||||
</Panel.Indent>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,27 +13,30 @@ import * as Panel from '../../panel-utils/PanelUtils';
|
|||||||
import CustomFieldEntry from './composite/CustomFieldEntry';
|
import CustomFieldEntry from './composite/CustomFieldEntry';
|
||||||
import CustomFieldForm from './composite/CustomFieldForm';
|
import CustomFieldForm from './composite/CustomFieldForm';
|
||||||
|
|
||||||
|
type CustomFieldEntity = CustomField & { key: CustomFieldKey };
|
||||||
|
|
||||||
|
type FormState = {
|
||||||
|
isOpen: boolean;
|
||||||
|
field?: CustomFieldEntity;
|
||||||
|
};
|
||||||
|
|
||||||
export default function CustomFieldSettings() {
|
export default function CustomFieldSettings() {
|
||||||
const { data, refetch } = useCustomFields();
|
const { data, refetch } = useCustomFields();
|
||||||
const [isAdding, setIsAdding] = useState(false);
|
const [formState, setFormState] = useState<FormState>({ isOpen: false, field: undefined });
|
||||||
|
|
||||||
const handleInitiateCreate = () => {
|
const openNewForm = () => setFormState({ isOpen: true });
|
||||||
setIsAdding(true);
|
const openEditForm = (field: CustomFieldEntity) => setFormState({ isOpen: true, field });
|
||||||
};
|
const closeForm = () => setFormState({ isOpen: false, field: undefined });
|
||||||
|
|
||||||
const handleCancel = () => {
|
const handleSubmit = async (customField: CustomField) => {
|
||||||
setIsAdding(false);
|
const editing = formState.field;
|
||||||
};
|
if (editing) {
|
||||||
|
await editCustomField(editing.key, customField);
|
||||||
const handleCreate = async (customField: CustomField) => {
|
} else {
|
||||||
await postCustomField(customField);
|
await postCustomField(customField);
|
||||||
refetch();
|
}
|
||||||
setIsAdding(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEditField = async (key: CustomFieldKey, customField: CustomField) => {
|
|
||||||
await editCustomField(key, customField);
|
|
||||||
refetch();
|
refetch();
|
||||||
|
closeForm();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (key: CustomFieldKey) => {
|
const handleDelete = async (key: CustomFieldKey) => {
|
||||||
@@ -45,12 +48,24 @@ export default function CustomFieldSettings() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const entries = Object.entries(data);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Panel.Section>
|
<Panel.Section>
|
||||||
<Panel.Card>
|
<Panel.Card>
|
||||||
|
{formState.isOpen && (
|
||||||
|
<CustomFieldForm
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
onCancel={closeForm}
|
||||||
|
initialColour={formState.field?.colour}
|
||||||
|
initialLabel={formState.field?.label}
|
||||||
|
initialKey={formState.field?.key}
|
||||||
|
initialType={formState.field?.type}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Panel.SubHeader>
|
<Panel.SubHeader>
|
||||||
Custom fields
|
Custom fields
|
||||||
<Button onClick={handleInitiateCreate}>
|
<Button onClick={openNewForm}>
|
||||||
New <IoAdd />
|
New <IoAdd />
|
||||||
</Button>
|
</Button>
|
||||||
</Panel.SubHeader>
|
</Panel.SubHeader>
|
||||||
@@ -66,7 +81,6 @@ export default function CustomFieldSettings() {
|
|||||||
</Info>
|
</Info>
|
||||||
</Panel.Section>
|
</Panel.Section>
|
||||||
<Panel.Section>
|
<Panel.Section>
|
||||||
{isAdding && <CustomFieldForm onSubmit={handleCreate} onCancel={handleCancel} />}
|
|
||||||
<Panel.Table>
|
<Panel.Table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -78,7 +92,18 @@ export default function CustomFieldSettings() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{Object.entries(data).map(([key, { colour, label, type }]) => {
|
{entries.length === 0 && (
|
||||||
|
<Panel.TableEmpty
|
||||||
|
title='No custom fields yet'
|
||||||
|
description='Custom fields add your own columns to the rundown, and can be shown in views or used in automations.'
|
||||||
|
action={
|
||||||
|
<Button variant='primary' onClick={openNewForm}>
|
||||||
|
Create custom field <IoAdd />
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{entries.map(([key, { colour, label, type }]) => {
|
||||||
return (
|
return (
|
||||||
<CustomFieldEntry
|
<CustomFieldEntry
|
||||||
key={key}
|
key={key}
|
||||||
@@ -86,7 +111,7 @@ export default function CustomFieldSettings() {
|
|||||||
colour={colour}
|
colour={colour}
|
||||||
label={label}
|
label={label}
|
||||||
type={type}
|
type={type}
|
||||||
onEdit={handleEditField}
|
onEdit={() => openEditForm({ key, colour, label, type })}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,11 +5,14 @@ import { uploadCustomView } from '../../../../common/api/customViews';
|
|||||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||||
import Button from '../../../../common/components/buttons/Button';
|
import Button from '../../../../common/components/buttons/Button';
|
||||||
import Input from '../../../../common/components/input/input/Input';
|
import Input from '../../../../common/components/input/input/Input';
|
||||||
|
import Modal from '../../../../common/components/modal/Modal';
|
||||||
import * as Panel from '../../panel-utils/PanelUtils';
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
import { getFileError, getSlugError, getViewUrl, maxUploadLabel } from './customViews.utils';
|
import { getFileError, getSlugError, getViewUrl, maxUploadLabel } from './customViews.utils';
|
||||||
|
|
||||||
import style from './CustomViews.module.scss';
|
import style from './CustomViews.module.scss';
|
||||||
|
|
||||||
|
const formId = 'custom-view-form';
|
||||||
|
|
||||||
interface CustomViewFormProps {
|
interface CustomViewFormProps {
|
||||||
onComplete: () => void;
|
onComplete: () => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -53,62 +56,72 @@ export default function CustomViewForm({ onComplete, onClose }: CustomViewFormPr
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Panel.Indent as='form' onSubmit={handleUpload} className={style.uploadForm}>
|
<Modal
|
||||||
<input
|
isOpen
|
||||||
ref={fileInputRef}
|
onClose={onClose}
|
||||||
style={{ display: 'none' }}
|
showBackdrop
|
||||||
type='file'
|
showCloseButton
|
||||||
onChange={handleSelectFile}
|
size='compact'
|
||||||
accept='.html,text/html'
|
title='Upload custom view'
|
||||||
/>
|
bodyElements={
|
||||||
|
<form id={formId} onSubmit={handleUpload} className={style.uploadForm}>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
type='file'
|
||||||
|
onChange={handleSelectFile}
|
||||||
|
accept='.html,text/html'
|
||||||
|
/>
|
||||||
|
|
||||||
<div className={style.step}>
|
<div className={style.step}>
|
||||||
<div className={style.stepTitle}>1. Choose a name</div>
|
<div className={style.stepTitle}>1. Choose a name</div>
|
||||||
<Panel.Description>Name</Panel.Description>
|
<Panel.Description>Name</Panel.Description>
|
||||||
<Input
|
<Input
|
||||||
value={slug}
|
value={slug}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
setSlug(event.target.value);
|
setSlug(event.target.value);
|
||||||
setSlugDirty(true);
|
setSlugDirty(true);
|
||||||
}}
|
}}
|
||||||
placeholder='my-view'
|
placeholder='my-view'
|
||||||
aria-label='Custom view name'
|
aria-label='Custom view name'
|
||||||
autoCapitalize='off'
|
autoCapitalize='off'
|
||||||
autoComplete='off'
|
autoComplete='off'
|
||||||
fluid
|
fluid
|
||||||
/>
|
/>
|
||||||
<Panel.Description>
|
<Panel.Description>
|
||||||
Use lowercase letters, numbers, and dashes. Example: <Panel.Highlight>my-view</Panel.Highlight>
|
Use lowercase letters, numbers, and dashes. Example: <Panel.Highlight>my-view</Panel.Highlight>
|
||||||
</Panel.Description>
|
</Panel.Description>
|
||||||
<Panel.Description>
|
<Panel.Description>
|
||||||
Preview URL: <Panel.Highlight>{previewUrl}</Panel.Highlight>
|
Preview URL: <Panel.Highlight>{previewUrl}</Panel.Highlight>
|
||||||
</Panel.Description>
|
</Panel.Description>
|
||||||
{slugDirty && slugError && <Panel.Error>{slugError}</Panel.Error>}
|
{slugDirty && slugError && <Panel.Error>{slugError}</Panel.Error>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={style.step}>
|
<div className={style.step}>
|
||||||
<div className={style.stepTitle}>2. Select index.html</div>
|
<div className={style.stepTitle}>2. Select index.html</div>
|
||||||
<Panel.Description>Upload file</Panel.Description>
|
<Panel.Description>Upload file</Panel.Description>
|
||||||
<Panel.InlineElements wrap='wrap' className={style.filePicker}>
|
<Panel.InlineElements wrap='wrap' className={style.filePicker}>
|
||||||
<Button onClick={() => fileInputRef.current?.click()}>
|
<Button onClick={() => fileInputRef.current?.click()}>
|
||||||
{selectedFile ? 'Replace index.html' : 'Choose index.html'}
|
{selectedFile ? 'Replace index.html' : 'Choose index.html'}
|
||||||
|
</Button>
|
||||||
|
<span className={style.fileName}>
|
||||||
|
{selectedFile ? `${selectedFile.name} (${Math.ceil(selectedFile.size / 1024)} KB)` : 'No file selected'}
|
||||||
|
</span>
|
||||||
|
</Panel.InlineElements>
|
||||||
|
<Panel.Description>Accepted: index.html only, maximum {maxUploadLabel}.</Panel.Description>
|
||||||
|
{fileDirty && fileError && <Panel.Error>{fileError}</Panel.Error>}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
footerElements={
|
||||||
|
<>
|
||||||
|
{error && <Panel.Error>{error}</Panel.Error>}
|
||||||
|
<Button onClick={onClose}>Cancel</Button>
|
||||||
|
<Button variant='primary' type='submit' form={formId} loading={isUploading} disabled={!canUpload}>
|
||||||
|
Upload view <IoCloudUploadOutline />
|
||||||
</Button>
|
</Button>
|
||||||
<span className={style.fileName}>
|
</>
|
||||||
{selectedFile ? `${selectedFile.name} (${Math.ceil(selectedFile.size / 1024)} KB)` : 'No file selected'}
|
}
|
||||||
</span>
|
/>
|
||||||
</Panel.InlineElements>
|
|
||||||
<Panel.Description>Accepted: index.html only, maximum {maxUploadLabel}.</Panel.Description>
|
|
||||||
{fileDirty && fileError && <Panel.Error>{fileError}</Panel.Error>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && <Panel.Error>{error}</Panel.Error>}
|
|
||||||
|
|
||||||
<Panel.InlineElements align='end'>
|
|
||||||
<Button onClick={onClose}>Cancel</Button>
|
|
||||||
<Button variant='primary' type='submit' loading={isUploading} disabled={!canUpload}>
|
|
||||||
Upload view <IoCloudUploadOutline />
|
|
||||||
</Button>
|
|
||||||
</Panel.InlineElements>
|
|
||||||
</Panel.Indent>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,13 +37,14 @@ export default function CustomViews() {
|
|||||||
return (
|
return (
|
||||||
<Panel.Section>
|
<Panel.Section>
|
||||||
<Panel.Card>
|
<Panel.Card>
|
||||||
|
{isUploadOpen && <CustomViewForm onComplete={handleUploadComplete} onClose={() => setIsUploadOpen(false)} />}
|
||||||
<Panel.SubHeader>
|
<Panel.SubHeader>
|
||||||
Custom views
|
Custom views
|
||||||
<Panel.InlineElements>
|
<Panel.InlineElements>
|
||||||
<Button variant='ghosted' onClick={handleRestoreDemo} disabled={hasDemoView}>
|
<Button variant='ghosted' onClick={handleRestoreDemo} disabled={hasDemoView}>
|
||||||
Restore demo
|
Restore demo
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={() => setIsUploadOpen(true)} disabled={isUploadOpen}>
|
<Button onClick={() => setIsUploadOpen(true)}>
|
||||||
New <IoAdd />
|
New <IoAdd />
|
||||||
</Button>
|
</Button>
|
||||||
</Panel.InlineElements>
|
</Panel.InlineElements>
|
||||||
@@ -63,8 +64,6 @@ export default function CustomViews() {
|
|||||||
<Panel.Section>
|
<Panel.Section>
|
||||||
<Panel.Loader isLoading={status === 'pending'} />
|
<Panel.Loader isLoading={status === 'pending'} />
|
||||||
|
|
||||||
{isUploadOpen && <CustomViewForm onComplete={handleUploadComplete} onClose={() => setIsUploadOpen(false)} />}
|
|
||||||
|
|
||||||
{actionError && <Panel.Error>{actionError}</Panel.Error>}
|
{actionError && <Panel.Error>{actionError}</Panel.Error>}
|
||||||
|
|
||||||
<CustomViewsList
|
<CustomViewsList
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { CustomViewSummary } from 'ontime-types';
|
import { CustomViewSummary } from 'ontime-types';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { IoCloudUploadOutline } from 'react-icons/io5';
|
||||||
|
|
||||||
import { deleteCustomView } from '../../../../common/api/customViews';
|
import { deleteCustomView } from '../../../../common/api/customViews';
|
||||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||||
@@ -53,7 +54,17 @@ export default function CustomViewsList({ views, onOpenUpload, onMutate, onError
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{views.length === 0 && <Panel.TableEmpty handleClick={onOpenUpload} label='No custom views yet' />}
|
{views.length === 0 && (
|
||||||
|
<Panel.TableEmpty
|
||||||
|
title='No custom views yet'
|
||||||
|
description='Upload a self-contained index.html to serve your own view alongside the built-in ones.'
|
||||||
|
action={
|
||||||
|
<Button variant='primary' onClick={onOpenUpload}>
|
||||||
|
Upload view <IoCloudUploadOutline />
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{views.map((view, index) => (
|
{views.map((view, index) => (
|
||||||
<CustomViewsListItem
|
<CustomViewsListItem
|
||||||
key={view.slug}
|
key={view.slug}
|
||||||
|
|||||||
@@ -12,6 +12,13 @@
|
|||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.current {
|
.formColumn {
|
||||||
background-color: $blue-1100 !important; // fighting zebra styles
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current {
|
||||||
|
// marker consumed by the shared Panel.Table active-row treatment
|
||||||
|
background-color: inherit;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,12 @@ import { useForm } from 'react-hook-form';
|
|||||||
|
|
||||||
import Button from '../../../../common/components/buttons/Button';
|
import Button from '../../../../common/components/buttons/Button';
|
||||||
import Input from '../../../../common/components/input/input/Input';
|
import Input from '../../../../common/components/input/input/Input';
|
||||||
|
import Modal from '../../../../common/components/modal/Modal';
|
||||||
import { useMutateProjectRundowns } from '../../../../common/hooks-query/useProjectRundowns';
|
import { useMutateProjectRundowns } from '../../../../common/hooks-query/useProjectRundowns';
|
||||||
import * as Panel from '../../panel-utils/PanelUtils';
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
|
|
||||||
|
const formId = 'new-rundown-form';
|
||||||
|
|
||||||
type NewRundownFormState = {
|
type NewRundownFormState = {
|
||||||
title: string;
|
title: string;
|
||||||
};
|
};
|
||||||
@@ -42,22 +45,32 @@ export function ManageRundownForm({ onClose }: ManageRundownForm) {
|
|||||||
}, [setFocus]);
|
}, [setFocus]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Panel.Indent as='form' onSubmit={handleSubmit(createRundown)}>
|
<Modal
|
||||||
<Panel.Section>
|
isOpen
|
||||||
<label>
|
onClose={onClose}
|
||||||
<Panel.Description>Rundown title</Panel.Description>
|
showBackdrop
|
||||||
<Input {...register('title')} maxLength={64} fluid placeholder='Your rundown name' />
|
showCloseButton
|
||||||
</label>
|
size='small'
|
||||||
</Panel.Section>
|
title='Create rundown'
|
||||||
<Panel.InlineElements relation='inner' align='end'>
|
bodyElements={
|
||||||
<Button variant='ghosted' disabled={isSubmitting} onClick={onClose}>
|
<form id={formId} onSubmit={handleSubmit(createRundown)}>
|
||||||
Cancel
|
<label>
|
||||||
</Button>
|
<Panel.Description>Rundown title</Panel.Description>
|
||||||
<Button type='submit' variant='primary' disabled={isSubmitting}>
|
<Input {...register('title')} fluid placeholder='Your rundown name' maxLength={64} />
|
||||||
Create rundown
|
</label>
|
||||||
</Button>
|
</form>
|
||||||
</Panel.InlineElements>
|
}
|
||||||
{errors.root && <Panel.Error>{errors.root.message}</Panel.Error>}
|
footerElements={
|
||||||
</Panel.Indent>
|
<>
|
||||||
|
{errors.root && <Panel.Error>{errors.root.message}</Panel.Error>}
|
||||||
|
<Button variant='ghosted' disabled={isSubmitting} onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type='submit' form={formId} variant='primary' disabled={isSubmitting}>
|
||||||
|
Create rundown
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ export default function ManageRundowns() {
|
|||||||
<tr key={id} className={cx([isLoaded && style.current])}>
|
<tr key={id} className={cx([isLoaded && style.current])}>
|
||||||
<td>{numEntries}</td>
|
<td>{numEntries}</td>
|
||||||
<td>
|
<td>
|
||||||
{title} {isLoaded && <Tag>Loaded</Tag>}
|
{title} {isLoaded && <Tag variant='active'>Loaded</Tag>}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<DropdownMenu
|
<DropdownMenu
|
||||||
|
|||||||
+2
-27
@@ -1,5 +1,4 @@
|
|||||||
import { CustomField, CustomFieldKey } from 'ontime-types';
|
import { CustomField, CustomFieldKey } from 'ontime-types';
|
||||||
import { useState } from 'react';
|
|
||||||
import { IoPencil, IoTrash } from 'react-icons/io5';
|
import { IoPencil, IoTrash } from 'react-icons/io5';
|
||||||
|
|
||||||
import IconButton from '../../../../../common/components/buttons/IconButton';
|
import IconButton from '../../../../../common/components/buttons/IconButton';
|
||||||
@@ -7,7 +6,6 @@ import CopyTag from '../../../../../common/components/copy-tag/CopyTag';
|
|||||||
import Swatch from '../../../../../common/components/input/colour-input/Swatch';
|
import Swatch from '../../../../../common/components/input/colour-input/Swatch';
|
||||||
import Tag from '../../../../../common/components/tag/Tag';
|
import Tag from '../../../../../common/components/tag/Tag';
|
||||||
import * as Panel from '../../../panel-utils/PanelUtils';
|
import * as Panel from '../../../panel-utils/PanelUtils';
|
||||||
import CustomFieldForm from './CustomFieldForm';
|
|
||||||
|
|
||||||
import style from '../ManagePanel.module.scss';
|
import style from '../ManagePanel.module.scss';
|
||||||
|
|
||||||
@@ -16,35 +14,12 @@ interface CustomFieldEntryProps {
|
|||||||
label: string;
|
label: string;
|
||||||
fieldKey: string;
|
fieldKey: string;
|
||||||
type: CustomField['type'];
|
type: CustomField['type'];
|
||||||
onEdit: (key: CustomFieldKey, patch: CustomField) => Promise<void>;
|
onEdit: () => void;
|
||||||
onDelete: (key: CustomFieldKey) => Promise<void>;
|
onDelete: (key: CustomFieldKey) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CustomFieldEntry(props: CustomFieldEntryProps) {
|
export default function CustomFieldEntry(props: CustomFieldEntryProps) {
|
||||||
const { colour, label, fieldKey, type, onEdit, onDelete } = props;
|
const { colour, label, fieldKey, type, onEdit, onDelete } = props;
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
|
||||||
|
|
||||||
const handleEdit = async (patch: CustomField) => {
|
|
||||||
await onEdit(fieldKey, patch);
|
|
||||||
setIsEditing(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isEditing) {
|
|
||||||
return (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={99}>
|
|
||||||
<CustomFieldForm
|
|
||||||
onCancel={() => setIsEditing(false)}
|
|
||||||
onSubmit={handleEdit}
|
|
||||||
initialColour={colour}
|
|
||||||
initialLabel={label}
|
|
||||||
initialKey={fieldKey}
|
|
||||||
initialType={type}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr>
|
<tr>
|
||||||
@@ -61,7 +36,7 @@ export default function CustomFieldEntry(props: CustomFieldEntryProps) {
|
|||||||
</CopyTag>
|
</CopyTag>
|
||||||
</td>
|
</td>
|
||||||
<Panel.InlineElements relation='inner' as='td'>
|
<Panel.InlineElements relation='inner' as='td'>
|
||||||
<IconButton variant='ghosted-white' aria-label='Edit entry' onClick={() => setIsEditing(true)}>
|
<IconButton variant='ghosted-white' aria-label='Edit entry' onClick={onEdit}>
|
||||||
<IoPencil />
|
<IoPencil />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<IconButton variant='ghosted-destructive' aria-label='Delete entry' onClick={() => onDelete(fieldKey)}>
|
<IconButton variant='ghosted-destructive' aria-label='Delete entry' onClick={() => onDelete(fieldKey)}>
|
||||||
|
|||||||
+74
-60
@@ -8,13 +8,15 @@ import Button from '../../../../../common/components/buttons/Button';
|
|||||||
import Info from '../../../../../common/components/info/Info';
|
import Info from '../../../../../common/components/info/Info';
|
||||||
import SwatchSelect from '../../../../../common/components/input/colour-input/SwatchSelect';
|
import SwatchSelect from '../../../../../common/components/input/colour-input/SwatchSelect';
|
||||||
import Input from '../../../../../common/components/input/input/Input';
|
import Input from '../../../../../common/components/input/input/Input';
|
||||||
|
import Modal from '../../../../../common/components/modal/Modal';
|
||||||
import RadioGroup from '../../../../../common/components/radio-group/RadioGroup';
|
import RadioGroup from '../../../../../common/components/radio-group/RadioGroup';
|
||||||
import useCustomFields from '../../../../../common/hooks-query/useCustomFields';
|
import useCustomFields from '../../../../../common/hooks-query/useCustomFields';
|
||||||
import { preventEscape } from '../../../../../common/utils/keyEvent';
|
|
||||||
import * as Panel from '../../../panel-utils/PanelUtils';
|
import * as Panel from '../../../panel-utils/PanelUtils';
|
||||||
|
|
||||||
import style from '../ManagePanel.module.scss';
|
import style from '../ManagePanel.module.scss';
|
||||||
|
|
||||||
|
const formId = 'custom-field-form';
|
||||||
|
|
||||||
interface CustomFieldsFormProps {
|
interface CustomFieldsFormProps {
|
||||||
onSubmit: (field: CustomField) => Promise<void>;
|
onSubmit: (field: CustomField) => Promise<void>;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
@@ -85,65 +87,77 @@ export default function CustomFieldForm({
|
|||||||
const isEditMode = initialKey !== undefined;
|
const isEditMode = initialKey !== undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Panel.Indent as='form' onSubmit={handleSubmit(setupSubmit)} onKeyDown={(event) => preventEscape(event, onCancel)}>
|
<Modal
|
||||||
<Info>
|
isOpen
|
||||||
Please note that images can quickly deteriorate your app's performance.
|
onClose={onCancel}
|
||||||
<br />
|
showBackdrop
|
||||||
Prefer using small, and compressed images.
|
showCloseButton
|
||||||
</Info>
|
size='compact'
|
||||||
<div>
|
title={isEditMode ? 'Edit custom field' : 'Create custom field'}
|
||||||
<Panel.Description>Type</Panel.Description>
|
bodyElements={
|
||||||
<RadioGroup
|
<form id={formId} onSubmit={handleSubmit(setupSubmit)} className={style.formColumn}>
|
||||||
orientation='horizontal'
|
<Info>
|
||||||
disabled={isEditMode}
|
Please note that images can quickly deteriorate your app's performance.
|
||||||
onValueChange={(value) => setValue('type', value, { shouldDirty: true })}
|
<br />
|
||||||
value={watch('type')}
|
Prefer using small, and compressed images.
|
||||||
items={[
|
</Info>
|
||||||
{ value: 'text', label: 'Text' },
|
<div>
|
||||||
{ value: 'image', label: 'Image' },
|
<Panel.Description>Type</Panel.Description>
|
||||||
]}
|
<RadioGroup
|
||||||
/>
|
orientation='horizontal'
|
||||||
</div>
|
disabled={isEditMode}
|
||||||
<div className={style.twoCols}>
|
onValueChange={(value) => setValue('type', value, { shouldDirty: true })}
|
||||||
<label>
|
value={watch('type')}
|
||||||
<Panel.Description>Label (only alphanumeric characters are allowed)</Panel.Description>
|
items={[
|
||||||
{errors.label && <Panel.Error>{errors.label.message}</Panel.Error>}
|
{ value: 'text', label: 'Text' },
|
||||||
<Input
|
{ value: 'image', label: 'Image' },
|
||||||
{...register('label', {
|
]}
|
||||||
required: { value: true, message: 'Required field' },
|
/>
|
||||||
onChange: () => setValue('key', customFieldLabelToKey(getValues('label')) ?? 'N/A'),
|
</div>
|
||||||
validate: (value) => {
|
<div className={style.twoCols}>
|
||||||
if (value.trim().length === 0) return 'Required field';
|
<label>
|
||||||
if (!checkRegex.isAlphanumericWithSpace(value))
|
<Panel.Description>Label (only alphanumeric characters are allowed)</Panel.Description>
|
||||||
return 'Only alphanumeric characters and space are allowed';
|
{errors.label && <Panel.Error>{errors.label.message}</Panel.Error>}
|
||||||
if (!isEditMode) {
|
<Input
|
||||||
if (isEditMode && Object.keys(data).includes(value)) return 'Custom fields must be unique';
|
{...register('label', {
|
||||||
}
|
required: { value: true, message: 'Required field' },
|
||||||
return true;
|
onChange: () => setValue('key', customFieldLabelToKey(getValues('label')) ?? 'N/A'),
|
||||||
},
|
validate: (value) => {
|
||||||
})}
|
if (value.trim().length === 0) return 'Required field';
|
||||||
fluid
|
if (!checkRegex.isAlphanumericWithSpace(value))
|
||||||
/>
|
return 'Only alphanumeric characters and space are allowed';
|
||||||
</label>
|
if (!isEditMode && Object.hasOwn(data, customFieldLabelToKey(value) ?? '')) {
|
||||||
|
return 'Custom fields must be unique';
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
fluid
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
<label>
|
<label>
|
||||||
<Panel.Description>Key (use in Integrations and API)</Panel.Description>
|
<Panel.Description>Key (use in Integrations and API)</Panel.Description>
|
||||||
<Input {...register('key')} variant='ghosted' readOnly fluid />
|
<Input {...register('key')} variant='ghosted' readOnly fluid />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<label>
|
<label>
|
||||||
<Panel.Description>Colour</Panel.Description>
|
<Panel.Description>Colour</Panel.Description>
|
||||||
<SwatchSelect name='colour' value={colour} handleChange={(_field, value) => handleSelectColour(value)} />
|
<SwatchSelect name='colour' value={colour} handleChange={(_field, value) => handleSelectColour(value)} />
|
||||||
</label>
|
</label>
|
||||||
{errors.root && <Panel.Error>{errors.root.message}</Panel.Error>}
|
</form>
|
||||||
<Panel.InlineElements relation='inner' align='end'>
|
}
|
||||||
<Button variant='ghosted' onClick={onCancel}>
|
footerElements={
|
||||||
Cancel
|
<>
|
||||||
</Button>
|
{errors.root && <Panel.Error>{errors.root.message}</Panel.Error>}
|
||||||
<Button type='submit' variant='primary' disabled={!canSubmit} loading={isSubmitting}>
|
<Button variant='ghosted' onClick={onCancel}>
|
||||||
Save
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
</Panel.InlineElements>
|
<Button type='submit' form={formId} variant='primary' disabled={!canSubmit} loading={isSubmitting}>
|
||||||
</Panel.Indent>
|
Save
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
-27
@@ -1,30 +1,3 @@
|
|||||||
.emptyState {
|
|
||||||
padding: 3rem 1.5rem;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.emptyMessage {
|
|
||||||
width: min(30rem, 100%);
|
|
||||||
margin-inline: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.emptyTitle {
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
color: rgba($gray-200, 0.72);
|
|
||||||
font-size: calc(1rem + 2px);
|
|
||||||
font-weight: 400;
|
|
||||||
}
|
|
||||||
|
|
||||||
.emptyBody {
|
|
||||||
color: rgba($gray-200, 0.55);
|
|
||||||
font-size: calc(1rem - 3px);
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.emptyAction {
|
|
||||||
margin: 1rem auto 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table {
|
.table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: separate;
|
border-collapse: separate;
|
||||||
|
|||||||
+10
-15
@@ -6,6 +6,7 @@ import { useMemo } from 'react';
|
|||||||
import Button from '../../../../../../../common/components/buttons/Button';
|
import Button from '../../../../../../../common/components/buttons/Button';
|
||||||
import Tag from '../../../../../../../common/components/tag/Tag';
|
import Tag from '../../../../../../../common/components/tag/Tag';
|
||||||
import { getRundownMetadata } from '../../../../../../../common/utils/rundownMetadata';
|
import { getRundownMetadata } from '../../../../../../../common/utils/rundownMetadata';
|
||||||
|
import * as Panel from '../../../../../panel-utils/PanelUtils';
|
||||||
import { getCellValue } from './previewTableUtils';
|
import { getCellValue } from './previewTableUtils';
|
||||||
|
|
||||||
import style from './PreviewTable.module.scss';
|
import style from './PreviewTable.module.scss';
|
||||||
@@ -123,23 +124,17 @@ export default function PreviewTable({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={style.emptyState}>
|
<Panel.EmptyState
|
||||||
<div className={style.emptyMessage}>
|
title={emptyTitle}
|
||||||
<div className={style.emptyTitle}>{emptyTitle}</div>
|
description={emptyContent}
|
||||||
<div className={style.emptyBody}>{emptyContent}</div>
|
action={
|
||||||
{needsPreviewRefresh && (
|
needsPreviewRefresh && (
|
||||||
<Button
|
<Button variant='primary' onClick={onRefresh} disabled={!canRefresh} loading={isRefreshing}>
|
||||||
className={style.emptyAction}
|
|
||||||
variant='primary'
|
|
||||||
onClick={onRefresh}
|
|
||||||
disabled={!canRefresh}
|
|
||||||
loading={isRefreshing}
|
|
||||||
>
|
|
||||||
Refresh preview
|
Refresh preview
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)
|
||||||
</div>
|
}
|
||||||
</div>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+18
-2
@@ -16,6 +16,22 @@
|
|||||||
user-select: text;
|
user-select: text;
|
||||||
}
|
}
|
||||||
|
|
||||||
.self {
|
.actionsCell {
|
||||||
background-color: $blue-1100;
|
width: 1%;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionsHeader {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionsGroup {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current {
|
||||||
|
// marker consumed by the shared Panel.Table active-row treatment
|
||||||
|
background-color: inherit;
|
||||||
}
|
}
|
||||||
|
|||||||
+46
-32
@@ -64,50 +64,58 @@ export default function ClientList() {
|
|||||||
<tr>
|
<tr>
|
||||||
<td style={{ width: '20%' }}>Client Name</td>
|
<td style={{ width: '20%' }}>Client Name</td>
|
||||||
<td>Path</td>
|
<td>Path</td>
|
||||||
<td />
|
<td className={style.actionsHeader} />
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
{ontimeClients.length === 0 && (
|
||||||
|
<Panel.TableEmpty
|
||||||
|
title='No Ontime clients connected'
|
||||||
|
description='Editor, cuesheet and operator windows appear here as they connect.'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{ontimeClients.map(([key, client]) => {
|
{ontimeClients.map(([key, client]) => {
|
||||||
const { identify, name, path } = client;
|
const { identify, name, path } = client;
|
||||||
const isCurrent = id === key;
|
const isCurrent = id === key;
|
||||||
return (
|
return (
|
||||||
<tr key={key} className={cx([isCurrent && style.self])}>
|
<tr key={key} className={cx([isCurrent && style.current])}>
|
||||||
<Panel.InlineElements relation='inner' as='td'>
|
<Panel.InlineElements relation='inner' as='td'>
|
||||||
{isCurrent && <Tag>SELF</Tag>}
|
{isCurrent && <Tag variant='active'>SELF</Tag>}
|
||||||
{name}
|
{name}
|
||||||
</Panel.InlineElements>
|
</Panel.InlineElements>
|
||||||
<td className={style.copiable}>{path}</td>
|
<td className={style.copiable}>{path}</td>
|
||||||
<Panel.InlineElements relation='inner' as='td'>
|
<td className={style.actionsCell}>
|
||||||
<Button
|
<Panel.InlineElements relation='inner' align='end' className={style.actionsGroup}>
|
||||||
size='small'
|
<Button
|
||||||
className={`${identify ? style.blink : ''}`}
|
size='small'
|
||||||
disabled={isCurrent}
|
className={`${identify ? style.blink : ''}`}
|
||||||
variant={identify ? 'primary' : 'subtle'}
|
disabled={isCurrent}
|
||||||
data-testid={isCurrent ? '' : 'not-self-identify'}
|
variant={identify ? 'primary' : 'subtle'}
|
||||||
onClick={() => {
|
data-testid={isCurrent ? '' : 'not-self-identify'}
|
||||||
setIdentify({ target: key, identify: !identify });
|
onClick={() => {
|
||||||
}}
|
setIdentify({ target: key, identify: !identify });
|
||||||
>
|
}}
|
||||||
Identify
|
>
|
||||||
</Button>
|
Identify
|
||||||
<Button
|
</Button>
|
||||||
size='small'
|
<Button
|
||||||
data-testid={isCurrent ? '' : 'not-self-rename'}
|
size='small'
|
||||||
onClick={() => openRename(key)}
|
data-testid={isCurrent ? '' : 'not-self-rename'}
|
||||||
>
|
onClick={() => openRename(key)}
|
||||||
Rename
|
>
|
||||||
</Button>
|
Rename
|
||||||
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
size='small'
|
size='small'
|
||||||
disabled={isCurrent}
|
disabled={isCurrent}
|
||||||
data-testid={isCurrent ? '' : 'not-self-redirect'}
|
data-testid={isCurrent ? '' : 'not-self-redirect'}
|
||||||
onClick={() => openRedirect(key)}
|
onClick={() => openRedirect(key)}
|
||||||
>
|
>
|
||||||
Redirect
|
Redirect
|
||||||
</Button>
|
</Button>
|
||||||
</Panel.InlineElements>
|
</Panel.InlineElements>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -125,6 +133,12 @@ export default function ClientList() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
{otherClients.length === 0 && (
|
||||||
|
<Panel.TableEmpty
|
||||||
|
title='No other clients connected'
|
||||||
|
description='Views and integrations connected to this server are listed here.'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{otherClients.map(([key, client]) => {
|
{otherClients.map(([key, client]) => {
|
||||||
const { name, type } = client;
|
const { name, type } = client;
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export default function ProjectList() {
|
|||||||
|
|
||||||
if (status === 'pending') {
|
if (status === 'pending') {
|
||||||
return (
|
return (
|
||||||
<div className={style.empty}>
|
<div className={style.loaderBox}>
|
||||||
<Panel.Loader isLoading />
|
<Panel.Loader isLoading />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -74,6 +74,12 @@ export default function ProjectList() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
{numProjects === 0 && (
|
||||||
|
<Panel.TableEmpty
|
||||||
|
title='No projects yet'
|
||||||
|
description='Projects are the show files Ontime saves your rundown and settings into. Create one to get started.'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{data.reorderedProjectFiles.map((project) => (
|
{data.reorderedProjectFiles.map((project) => (
|
||||||
<ProjectListItem
|
<ProjectListItem
|
||||||
key={project.filename}
|
key={project.filename}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import Button from '../../../../common/components/buttons/Button';
|
|||||||
import IconButton from '../../../../common/components/buttons/IconButton';
|
import IconButton from '../../../../common/components/buttons/IconButton';
|
||||||
import Dialog from '../../../../common/components/dialog/Dialog';
|
import Dialog from '../../../../common/components/dialog/Dialog';
|
||||||
import { DropdownMenu } from '../../../../common/components/dropdown-menu/DropdownMenu';
|
import { DropdownMenu } from '../../../../common/components/dropdown-menu/DropdownMenu';
|
||||||
|
import Tag from '../../../../common/components/tag/Tag';
|
||||||
import { cx } from '../../../../common/utils/styleUtils';
|
import { cx } from '../../../../common/utils/styleUtils';
|
||||||
import * as Panel from '../../panel-utils/PanelUtils';
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
import ProjectForm, { ProjectFormValues } from './ProjectForm';
|
import ProjectForm, { ProjectFormValues } from './ProjectForm';
|
||||||
@@ -143,7 +144,7 @@ export default function ProjectListItem({
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<td className={style.containCell}>{filename}</td>
|
<td className={style.containCell}>{filename}</td>
|
||||||
<td>{current ? 'Currently loaded' : new Date(updatedAt).toLocaleString()}</td>
|
<td>{current ? <Tag variant='active'>Loaded</Tag> : new Date(updatedAt).toLocaleString()}</td>
|
||||||
<td>
|
<td>
|
||||||
<ActionMenu
|
<ActionMenu
|
||||||
current={current}
|
current={current}
|
||||||
@@ -158,13 +159,7 @@ export default function ProjectListItem({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</tr>
|
</tr>
|
||||||
{showMergeForm && (
|
{showMergeForm && <ProjectMergeForm onClose={handleCancel} fileName={filename} />}
|
||||||
<tr>
|
|
||||||
<td colSpan={99}>
|
|
||||||
<ProjectMergeForm onClose={handleCancel} fileName={filename} />
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
<Dialog
|
<Dialog
|
||||||
isOpen={isDeleteOpen}
|
isOpen={isDeleteOpen}
|
||||||
onClose={() => setDeleteOpen(false)}
|
onClose={() => setDeleteOpen(false)}
|
||||||
|
|||||||
@@ -7,13 +7,15 @@ import { getDb, patchData } from '../../../../common/api/db';
|
|||||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||||
import Button from '../../../../common/components/buttons/Button';
|
import Button from '../../../../common/components/buttons/Button';
|
||||||
import Info from '../../../../common/components/info/Info';
|
import Info from '../../../../common/components/info/Info';
|
||||||
|
import Modal from '../../../../common/components/modal/Modal';
|
||||||
import Switch from '../../../../common/components/switch/Switch';
|
import Switch from '../../../../common/components/switch/Switch';
|
||||||
import { cx } from '../../../../common/utils/styleUtils';
|
|
||||||
import * as Panel from '../../panel-utils/PanelUtils';
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
import { makeProjectPatch } from './project.utils';
|
import { makeProjectPatch } from './project.utils';
|
||||||
|
|
||||||
import style from './ProjectPanel.module.scss';
|
import style from './ProjectPanel.module.scss';
|
||||||
|
|
||||||
|
const formId = 'project-merge-form';
|
||||||
|
|
||||||
interface ProjectMergeFromProps {
|
interface ProjectMergeFromProps {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
fileName: string;
|
fileName: string;
|
||||||
@@ -77,68 +79,122 @@ export default function ProjectMergeForm({ onClose, fileName }: ProjectMergeFrom
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Panel.Section as='form' onSubmit={handleSubmit(handleSubmitCreate)}>
|
<Modal
|
||||||
<Panel.Title>
|
isOpen
|
||||||
Partial project merge
|
onClose={onClose}
|
||||||
<Panel.InlineElements>
|
showBackdrop
|
||||||
<Button onClick={onClose} variant='ghosted' disabled={isSubmitting}>
|
showCloseButton
|
||||||
Cancel
|
size='compact'
|
||||||
</Button>
|
title='Partial project merge'
|
||||||
<Button type='submit' disabled={!isValid || !isDirty} loading={isSubmitting} variant='primary'>
|
bodyElements={
|
||||||
Merge
|
<form id={formId} onSubmit={handleSubmit(handleSubmitCreate)}>
|
||||||
</Button>
|
<Panel.Section className={style.mergeBody}>
|
||||||
</Panel.InlineElements>
|
<div className={style.mergeIntro}>
|
||||||
</Panel.Title>
|
<Panel.Description>
|
||||||
{error && <Panel.Error>{error}</Panel.Error>}
|
Select the sections from <span className={style.sourceFile}>{`"${fileName}"`}</span> that you want to
|
||||||
<Panel.Section className={cx([style.innerColumn, style.inlineLabels])}>
|
merge into the current project.
|
||||||
<Panel.Description>
|
</Panel.Description>
|
||||||
Select data from <i>{`"${fileName}"`}</i> to merge into the current project.
|
<div className={style.mergeSummary}>
|
||||||
</Panel.Description>
|
Only the enabled sections are copied. Existing data stays intact.
|
||||||
<Info type='warning'>
|
</div>
|
||||||
This process is irreversible and can result in data loss. <br />
|
</div>
|
||||||
You may want to create a duplicate backup beforehand.
|
|
||||||
</Info>
|
<Info type='warning' className={style.mergeWarningInfo}>
|
||||||
<label>
|
<Info.Title>Back up before merging</Info.Title>
|
||||||
<Switch
|
<Info.Body>
|
||||||
size='large'
|
This action can overwrite project data. Create a duplicate project first if you want a recovery point.
|
||||||
checked={watch('project')}
|
</Info.Body>
|
||||||
onCheckedChange={(value: boolean) => setValue('project', value, { shouldDirty: true })}
|
</Info>
|
||||||
/>
|
|
||||||
Project data
|
<Panel.ListGroup className={style.optionList}>
|
||||||
</label>
|
<Panel.ListItem>
|
||||||
<label>
|
<label className={style.optionRow}>
|
||||||
<Switch
|
<div className={style.optionCopy}>
|
||||||
size='large'
|
<span className={style.optionTitle}>Project data</span>
|
||||||
checked={watch('rundowns')}
|
<span className={style.optionBody}>Core project metadata and settings.</span>
|
||||||
onCheckedChange={(value: boolean) => setValue('rundowns', value, { shouldDirty: true })}
|
</div>
|
||||||
/>
|
<Switch
|
||||||
Rundown + Custom Fields
|
size='large'
|
||||||
</label>
|
checked={watch('project')}
|
||||||
<label>
|
onCheckedChange={(value: boolean) => setValue('project', value, { shouldDirty: true })}
|
||||||
<Switch
|
/>
|
||||||
size='large'
|
</label>
|
||||||
checked={watch('viewSettings')}
|
</Panel.ListItem>
|
||||||
onCheckedChange={(value: boolean) => setValue('viewSettings', value, { shouldDirty: true })}
|
<Panel.ListItem>
|
||||||
/>
|
<label className={style.optionRow}>
|
||||||
View Settings
|
<div className={style.optionCopy}>
|
||||||
</label>
|
<span className={style.optionTitle}>Rundown + Custom Fields</span>
|
||||||
<label>
|
<span className={style.optionBody}>Copies all rundowns and any associated custom fields.</span>
|
||||||
<Switch
|
</div>
|
||||||
size='large'
|
<Switch
|
||||||
checked={watch('urlPresets')}
|
size='large'
|
||||||
onCheckedChange={(value: boolean) => setValue('urlPresets', value, { shouldDirty: true })}
|
checked={watch('rundowns')}
|
||||||
/>
|
onCheckedChange={(value: boolean) => setValue('rundowns', value, { shouldDirty: true })}
|
||||||
URL Presets
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
</Panel.ListItem>
|
||||||
<Switch
|
<Panel.ListItem>
|
||||||
size='large'
|
<label className={style.optionRow}>
|
||||||
checked={watch('automation')}
|
<div className={style.optionCopy}>
|
||||||
onCheckedChange={(value: boolean) => setValue('automation', value, { shouldDirty: true })}
|
<span className={style.optionTitle}>View Settings</span>
|
||||||
/>
|
<span className={style.optionBody}>View configuration, and display preferences.</span>
|
||||||
Automation Settings
|
</div>
|
||||||
</label>
|
<Switch
|
||||||
</Panel.Section>
|
size='large'
|
||||||
</Panel.Section>
|
checked={watch('viewSettings')}
|
||||||
|
onCheckedChange={(value: boolean) => setValue('viewSettings', value, { shouldDirty: true })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</Panel.ListItem>
|
||||||
|
<Panel.ListItem>
|
||||||
|
<label className={style.optionRow}>
|
||||||
|
<div className={style.optionCopy}>
|
||||||
|
<span className={style.optionTitle}>URL Presets</span>
|
||||||
|
<span className={style.optionBody}>Saved links and preset launch parameters.</span>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
size='large'
|
||||||
|
checked={watch('urlPresets')}
|
||||||
|
onCheckedChange={(value: boolean) => setValue('urlPresets', value, { shouldDirty: true })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</Panel.ListItem>
|
||||||
|
<Panel.ListItem>
|
||||||
|
<label className={style.optionRow}>
|
||||||
|
<div className={style.optionCopy}>
|
||||||
|
<span className={style.optionTitle}>Automation Settings</span>
|
||||||
|
<span className={style.optionBody}>Triggers and automation configuration.</span>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
size='large'
|
||||||
|
checked={watch('automation')}
|
||||||
|
onCheckedChange={(value: boolean) => setValue('automation', value, { shouldDirty: true })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</Panel.ListItem>
|
||||||
|
</Panel.ListGroup>
|
||||||
|
</Panel.Section>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
footerElements={
|
||||||
|
<div className={style.footerContent}>
|
||||||
|
{error && <Panel.Error>{error}</Panel.Error>}
|
||||||
|
<div className={style.footerActions}>
|
||||||
|
<Button onClick={onClose} variant='ghosted' disabled={isSubmitting}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type='submit'
|
||||||
|
form={formId}
|
||||||
|
disabled={!isValid || !isDirty}
|
||||||
|
loading={isSubmitting}
|
||||||
|
variant='primary'
|
||||||
|
>
|
||||||
|
Merge
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
.current {
|
.current {
|
||||||
background-color: $blue-1100;
|
// marker consumed by the shared Panel.Table active-row treatment
|
||||||
|
background-color: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.isEditing {
|
.isEditing {
|
||||||
@@ -59,3 +60,86 @@
|
|||||||
height: 300px;
|
height: 300px;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mergeBody {
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mergeIntro {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sourceFile {
|
||||||
|
padding: 0.1rem 0.45rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background-color: $white-3;
|
||||||
|
color: $orange-400;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mergeSummary {
|
||||||
|
color: $gray-400;
|
||||||
|
font-size: calc(1rem - 2px);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mergeWarningInfo {
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.optionList {
|
||||||
|
padding: 0;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.optionList > li:not(:last-child) {
|
||||||
|
border-bottom: 1px solid $white-10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.optionRow {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.optionCopy {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.2rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.optionTitle {
|
||||||
|
color: $ui-white;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.optionBody {
|
||||||
|
color: $gray-400;
|
||||||
|
font-size: calc(1rem - 3px);
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footerContent {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footerActions {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.75rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|||||||
+2
-5
@@ -90,16 +90,13 @@ export default function CustomTranslationModal({ isOpen, onClose }: CustomTransl
|
|||||||
<div className={style.footer}>
|
<div className={style.footer}>
|
||||||
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}
|
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}
|
||||||
<Panel.InlineElements align='apart'>
|
<Panel.InlineElements align='apart'>
|
||||||
<Button variant='ghosted' size='large' onClick={resetToEnglish} disabled={isSubmitting}>
|
<Button variant='ghosted' onClick={resetToEnglish} disabled={isSubmitting}>
|
||||||
Reset to English
|
Reset to English
|
||||||
</Button>
|
</Button>
|
||||||
<Panel.InlineElements>
|
<Panel.InlineElements>
|
||||||
<Button size='large' onClick={onClose}>
|
<Button onClick={onClose}>Cancel</Button>
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
<Button
|
||||||
variant='primary'
|
variant='primary'
|
||||||
size='large'
|
|
||||||
type='submit'
|
type='submit'
|
||||||
form='custom-translations-form'
|
form='custom-translations-form'
|
||||||
disabled={isSubmitting || !isDirty || !isValid}
|
disabled={isSubmitting || !isDirty || !isValid}
|
||||||
|
|||||||
+3
-6
@@ -109,19 +109,16 @@ export default function CodeEditorModal({ isOpen, onClose }: CodeEditorModalProp
|
|||||||
<Info>Invalid CSS will be refused by the browser</Info>
|
<Info>Invalid CSS will be refused by the browser</Info>
|
||||||
{error && <Panel.Error className={style.right}>{`Error: ${error}`}</Panel.Error>}
|
{error && <Panel.Error className={style.right}>{`Error: ${error}`}</Panel.Error>}
|
||||||
<Panel.InlineElements align='apart' className={style.editorActions}>
|
<Panel.InlineElements align='apart' className={style.editorActions}>
|
||||||
<Button variant='ghosted' size='large' onClick={handleRestore} disabled={saveLoading || resetLoading}>
|
<Button variant='ghosted' onClick={handleRestore} disabled={saveLoading || resetLoading}>
|
||||||
Reset to example
|
Reset to example
|
||||||
</Button>
|
</Button>
|
||||||
<Panel.InlineElements>
|
<Panel.InlineElements>
|
||||||
<Button variant='ghosted' size='large' onClick={clear}>
|
<Button variant='ghosted' onClick={clear}>
|
||||||
Clear
|
Clear
|
||||||
</Button>
|
</Button>
|
||||||
<Button size='large' onClick={onClose}>
|
<Button onClick={onClose}>Cancel</Button>
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
<Button
|
||||||
variant='primary'
|
variant='primary'
|
||||||
size='large'
|
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={saveLoading || resetLoading || !isDirty}
|
disabled={saveLoading || resetLoading || !isDirty}
|
||||||
loading={saveLoading}
|
loading={saveLoading}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useRef } from 'react';
|
|||||||
|
|
||||||
import Button from '../../../../common/components/buttons/Button';
|
import Button from '../../../../common/components/buttons/Button';
|
||||||
import Dialog from '../../../../common/components/dialog/Dialog';
|
import Dialog from '../../../../common/components/dialog/Dialog';
|
||||||
|
import Info from '../../../../common/components/info/Info';
|
||||||
import { useElectronEvent } from '../../../../common/hooks/useElectronEvent';
|
import { useElectronEvent } from '../../../../common/hooks/useElectronEvent';
|
||||||
import { isLocalhost, isOntimeCloud } from '../../../../externals';
|
import { isLocalhost, isOntimeCloud } from '../../../../externals';
|
||||||
import * as Panel from '../../panel-utils/PanelUtils';
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
@@ -23,44 +24,50 @@ export default function ShutdownPanel() {
|
|||||||
<>
|
<>
|
||||||
<Panel.Header>Shutdown Ontime</Panel.Header>
|
<Panel.Header>Shutdown Ontime</Panel.Header>
|
||||||
<Panel.Section>
|
<Panel.Section>
|
||||||
{isOntimeCloud ? (
|
<Panel.Card>
|
||||||
<Panel.Highlight>
|
<Panel.SubHeader>Shutdown</Panel.SubHeader>
|
||||||
For security reasons, shutting down the server must be done from the Ontime Cloud dashboard.
|
<Panel.Divider />
|
||||||
</Panel.Highlight>
|
{isOntimeCloud ? (
|
||||||
) : (
|
<Info type='warning'>
|
||||||
<Panel.Paragraph>
|
For security reasons, shutting down the server must be done from the Ontime Cloud dashboard.
|
||||||
This will shutdown the Ontime server. <br />
|
</Info>
|
||||||
The runtime state will be lost, but your project is kept for next time.
|
) : (
|
||||||
</Panel.Paragraph>
|
<Panel.Field
|
||||||
)}
|
title='Stop the Ontime server'
|
||||||
<Button variant='destructive' onClick={handler.open} disabled={!(isElectron || isLocalhost)}>
|
description='The runtime state will be lost, but your project is kept for next time.'
|
||||||
Shutdown ontime
|
/>
|
||||||
</Button>
|
)}
|
||||||
{!canShutdown && (
|
{!isOntimeCloud && (
|
||||||
<Panel.Description>Note: Ontime can only be shutdown from the machine it is running in.</Panel.Description>
|
<Panel.Section>
|
||||||
)}
|
<Button variant='destructive' onClick={handler.open} disabled={!canShutdown}>
|
||||||
<Dialog
|
Shutdown ontime
|
||||||
isOpen={isOpen}
|
|
||||||
title='Shutdown Ontime'
|
|
||||||
showCloseButton
|
|
||||||
onClose={handler.close}
|
|
||||||
bodyElements={
|
|
||||||
<Panel.Paragraph>
|
|
||||||
This will shutdown the Ontime server. <br /> Are you sure?
|
|
||||||
</Panel.Paragraph>
|
|
||||||
}
|
|
||||||
footerElements={
|
|
||||||
<>
|
|
||||||
<Button ref={cancelRef} onClick={handler.close} variant='ghosted-white'>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant='destructive' onClick={sendShutdown} disabled={!canShutdown}>
|
{!canShutdown && <Panel.Description>Only available from the machine running Ontime.</Panel.Description>}
|
||||||
Shutdown
|
</Panel.Section>
|
||||||
</Button>
|
)}
|
||||||
</>
|
</Panel.Card>
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Panel.Section>
|
</Panel.Section>
|
||||||
|
<Dialog
|
||||||
|
isOpen={isOpen}
|
||||||
|
title='Shutdown Ontime'
|
||||||
|
showCloseButton
|
||||||
|
onClose={handler.close}
|
||||||
|
bodyElements={
|
||||||
|
<Panel.Paragraph>
|
||||||
|
This will shutdown the Ontime server. <br /> Are you sure?
|
||||||
|
</Panel.Paragraph>
|
||||||
|
}
|
||||||
|
footerElements={
|
||||||
|
<>
|
||||||
|
<Button ref={cancelRef} onClick={handler.close} variant='ghosted-white'>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button variant='destructive' onClick={sendShutdown} disabled={!canShutdown}>
|
||||||
|
Shutdown
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,54 @@
|
|||||||
$info-gray: $secondary-text-gray;
|
$info-gray: $secondary-text-gray;
|
||||||
$info-hover: $section-white;
|
$info-hover: $section-white;
|
||||||
|
|
||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.extracted {
|
||||||
|
height: 100%;
|
||||||
|
padding: 1.5rem;
|
||||||
|
background-color: $white-3;
|
||||||
|
border: 1px solid $gray-1100;
|
||||||
|
border-radius: $component-border-radius-md;
|
||||||
|
|
||||||
|
.log {
|
||||||
|
flex: 1;
|
||||||
|
height: auto;
|
||||||
|
min-height: 0;
|
||||||
|
max-height: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.log {
|
.log {
|
||||||
font-size: $inner-section-text-size;
|
font-size: $inner-section-text-size;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
user-select: text;
|
user-select: text;
|
||||||
min-height: 20vh;
|
height: 40vh;
|
||||||
|
min-height: 40vh;
|
||||||
max-height: 40vh;
|
max-height: 40vh;
|
||||||
background-color: $gray-1350;
|
margin: 0;
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
|
background-color: $gray-1350;
|
||||||
|
border: 1px solid $gray-1100;
|
||||||
|
border-radius: $component-border-radius-md;
|
||||||
|
box-shadow: inset 0 1px 0 $white-3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.apart {
|
.apart {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.filterLabel {
|
||||||
|
color: $gray-400;
|
||||||
|
font-size: $aux-text-size;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
.logEntry {
|
.logEntry {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
||||||
@@ -44,10 +78,19 @@ $info-hover: $section-white;
|
|||||||
|
|
||||||
.msg {
|
.msg {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
padding: 2.5rem 1rem;
|
||||||
|
color: $gray-600;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
.buttonBar {
|
.buttonBar {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
padding-bottom: 0.5rem;
|
padding-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,12 +4,14 @@ import { IoClose } from 'react-icons/io5';
|
|||||||
|
|
||||||
import Button from '../../common/components/buttons/Button';
|
import Button from '../../common/components/buttons/Button';
|
||||||
import { clearLogs, useLogData } from '../../common/stores/logger';
|
import { clearLogs, useLogData } from '../../common/stores/logger';
|
||||||
|
import { cx } from '../../common/utils/styleUtils';
|
||||||
import * as Panel from '../app-settings/panel-utils/PanelUtils';
|
import * as Panel from '../app-settings/panel-utils/PanelUtils';
|
||||||
|
|
||||||
import style from './Log.module.scss';
|
import style from './Log.module.scss';
|
||||||
|
|
||||||
export default function Log() {
|
export default function Log() {
|
||||||
const { logs: logData } = useLogData();
|
const { logs: logData } = useLogData();
|
||||||
|
const isExtracted = window.location.pathname.includes('/log');
|
||||||
|
|
||||||
const [showClient, setShowClient] = useState(true);
|
const [showClient, setShowClient] = useState(true);
|
||||||
const [showServer, setShowServer] = useState(true);
|
const [showServer, setShowServer] = useState(true);
|
||||||
@@ -50,11 +52,14 @@ export default function Log() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className={cx([style.container, isExtracted && style.extracted])}>
|
||||||
<Panel.InlineElements className={style.buttonBar}>
|
<Panel.InlineElements className={style.buttonBar}>
|
||||||
|
<span className={style.filterLabel}>Filter by</span>
|
||||||
<Button
|
<Button
|
||||||
variant={showUser ? 'primary' : 'subtle'}
|
variant={showUser ? 'primary' : 'subtle'}
|
||||||
size='small'
|
size='small'
|
||||||
|
aria-pressed={showUser}
|
||||||
|
aria-label={`${showUser ? 'Hide' : 'Show'} ${LogOrigin.User} events`}
|
||||||
onClick={() => setShowUser((s) => !s)}
|
onClick={() => setShowUser((s) => !s)}
|
||||||
onAuxClick={() => disableOthers(LogOrigin.User)}
|
onAuxClick={() => disableOthers(LogOrigin.User)}
|
||||||
onContextMenu={(e) => e.preventDefault()}
|
onContextMenu={(e) => e.preventDefault()}
|
||||||
@@ -64,6 +69,8 @@ export default function Log() {
|
|||||||
<Button
|
<Button
|
||||||
variant={showClient ? 'primary' : 'subtle'}
|
variant={showClient ? 'primary' : 'subtle'}
|
||||||
size='small'
|
size='small'
|
||||||
|
aria-pressed={showClient}
|
||||||
|
aria-label={`${showClient ? 'Hide' : 'Show'} ${LogOrigin.Client} events`}
|
||||||
onClick={() => setShowClient((s) => !s)}
|
onClick={() => setShowClient((s) => !s)}
|
||||||
onAuxClick={() => disableOthers(LogOrigin.Client)}
|
onAuxClick={() => disableOthers(LogOrigin.Client)}
|
||||||
onContextMenu={(e) => e.preventDefault()}
|
onContextMenu={(e) => e.preventDefault()}
|
||||||
@@ -73,6 +80,8 @@ export default function Log() {
|
|||||||
<Button
|
<Button
|
||||||
variant={showServer ? 'primary' : 'subtle'}
|
variant={showServer ? 'primary' : 'subtle'}
|
||||||
size='small'
|
size='small'
|
||||||
|
aria-pressed={showServer}
|
||||||
|
aria-label={`${showServer ? 'Hide' : 'Show'} ${LogOrigin.Server} events`}
|
||||||
onClick={() => setShowServer((s) => !s)}
|
onClick={() => setShowServer((s) => !s)}
|
||||||
onAuxClick={() => disableOthers(LogOrigin.Server)}
|
onAuxClick={() => disableOthers(LogOrigin.Server)}
|
||||||
onContextMenu={(e) => e.preventDefault()}
|
onContextMenu={(e) => e.preventDefault()}
|
||||||
@@ -82,6 +91,8 @@ export default function Log() {
|
|||||||
<Button
|
<Button
|
||||||
variant={showPlayback ? 'primary' : 'subtle'}
|
variant={showPlayback ? 'primary' : 'subtle'}
|
||||||
size='small'
|
size='small'
|
||||||
|
aria-pressed={showPlayback}
|
||||||
|
aria-label={`${showPlayback ? 'Hide' : 'Show'} ${LogOrigin.Playback} events`}
|
||||||
onClick={() => setShowPlayback((s) => !s)}
|
onClick={() => setShowPlayback((s) => !s)}
|
||||||
onAuxClick={() => disableOthers(LogOrigin.Playback)}
|
onAuxClick={() => disableOthers(LogOrigin.Playback)}
|
||||||
onContextMenu={(e) => e.preventDefault()}
|
onContextMenu={(e) => e.preventDefault()}
|
||||||
@@ -91,6 +102,8 @@ export default function Log() {
|
|||||||
<Button
|
<Button
|
||||||
variant={showRx ? 'primary' : 'subtle'}
|
variant={showRx ? 'primary' : 'subtle'}
|
||||||
size='small'
|
size='small'
|
||||||
|
aria-pressed={showRx}
|
||||||
|
aria-label={`${showRx ? 'Hide' : 'Show'} ${LogOrigin.Rx} events`}
|
||||||
onClick={() => setShowRx((s) => !s)}
|
onClick={() => setShowRx((s) => !s)}
|
||||||
onAuxClick={() => disableOthers(LogOrigin.Rx)}
|
onAuxClick={() => disableOthers(LogOrigin.Rx)}
|
||||||
onContextMenu={(e) => e.preventDefault()}
|
onContextMenu={(e) => e.preventDefault()}
|
||||||
@@ -100,6 +113,8 @@ export default function Log() {
|
|||||||
<Button
|
<Button
|
||||||
variant={showTx ? 'primary' : 'subtle'}
|
variant={showTx ? 'primary' : 'subtle'}
|
||||||
size='small'
|
size='small'
|
||||||
|
aria-pressed={showTx}
|
||||||
|
aria-label={`${showTx ? 'Hide' : 'Show'} ${LogOrigin.Tx} events`}
|
||||||
onClick={() => setShowTx((s) => !s)}
|
onClick={() => setShowTx((s) => !s)}
|
||||||
onAuxClick={() => disableOthers(LogOrigin.Tx)}
|
onAuxClick={() => disableOthers(LogOrigin.Tx)}
|
||||||
onContextMenu={(e) => e.preventDefault()}
|
onContextMenu={(e) => e.preventDefault()}
|
||||||
@@ -110,15 +125,16 @@ export default function Log() {
|
|||||||
<IoClose /> Clear
|
<IoClose /> Clear
|
||||||
</Button>
|
</Button>
|
||||||
</Panel.InlineElements>
|
</Panel.InlineElements>
|
||||||
<ul className={style.log}>
|
<ul className={style.log} aria-label='Event log entries'>
|
||||||
|
{filteredData.length === 0 && <li className={style.empty}>No events match the selected filters.</li>}
|
||||||
{filteredData.map((logEntry) => (
|
{filteredData.map((logEntry) => (
|
||||||
<li key={logEntry.id} className={`${style.logEntry} ${style[logEntry.level]} `}>
|
<li key={logEntry.id} className={`${style.logEntry} ${style[logEntry.level]}`}>
|
||||||
<span className={style.time}>{logEntry.time}</span>
|
<span className={style.time}>{logEntry.time}</span>
|
||||||
<span className={style.origin}>{logEntry.origin}</span>
|
<span className={style.origin}>{logEntry.origin}</span>
|
||||||
<span className={style.msg}>{logEntry.text}</span>
|
<span className={style.msg}>{logEntry.text}</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,10 +7,11 @@ test('redirect', async ({ context }) => {
|
|||||||
await controllerPage.goto('/editor?settings=network__clients');
|
await controllerPage.goto('/editor?settings=network__clients');
|
||||||
await remotePage.goto('/timer');
|
await remotePage.goto('/timer');
|
||||||
|
|
||||||
await controllerPage.getByTestId('not-self-redirect').click();
|
const remoteClient = controllerPage.getByRole('row').filter({ hasText: /\/timer/ });
|
||||||
|
await remoteClient.getByTestId('not-self-redirect').click();
|
||||||
await controllerPage.getByRole('textbox', { name: 'http://localhost:' }).click();
|
await controllerPage.getByRole('textbox', { name: 'http://localhost:' }).click();
|
||||||
await controllerPage.getByRole('textbox', { name: 'http://localhost:' }).fill('studio');
|
await controllerPage.getByRole('textbox', { name: 'http://localhost:' }).fill('studio');
|
||||||
await controllerPage.getByLabel('Redirect', { exact: true }).click();
|
await controllerPage.getByRole('button', { name: 'Redirect to custom path' }).click();
|
||||||
|
|
||||||
await expect(remotePage.getByTestId('studio-view')).toBeVisible();
|
await expect(remotePage.getByTestId('studio-view')).toBeVisible();
|
||||||
});
|
});
|
||||||
@@ -22,7 +23,11 @@ test('identify', async ({ context }) => {
|
|||||||
await controllerPage.goto('/editor?settings=network__clients');
|
await controllerPage.goto('/editor?settings=network__clients');
|
||||||
await remotePage.goto('/timer');
|
await remotePage.goto('/timer');
|
||||||
|
|
||||||
await controllerPage.getByTestId('not-self-identify').click();
|
await controllerPage
|
||||||
|
.getByRole('row')
|
||||||
|
.filter({ hasText: /\/timer/ })
|
||||||
|
.getByTestId('not-self-identify')
|
||||||
|
.click();
|
||||||
|
|
||||||
await expect(remotePage.getByTestId('identify-overlay')).toBeVisible();
|
await expect(remotePage.getByTestId('identify-overlay')).toBeVisible();
|
||||||
});
|
});
|
||||||
@@ -34,11 +39,19 @@ test('rename', async ({ context }) => {
|
|||||||
await controllerPage.goto('/editor?settings=network__clients');
|
await controllerPage.goto('/editor?settings=network__clients');
|
||||||
await remotePage.goto('/timer');
|
await remotePage.goto('/timer');
|
||||||
|
|
||||||
await controllerPage.getByTestId('not-self-rename').click();
|
await controllerPage
|
||||||
|
.getByRole('row')
|
||||||
|
.filter({ hasText: /\/timer/ })
|
||||||
|
.getByTestId('not-self-rename')
|
||||||
|
.click();
|
||||||
await controllerPage.getByPlaceholder('new name').click();
|
await controllerPage.getByPlaceholder('new name').click();
|
||||||
await controllerPage.getByPlaceholder('new name').fill('test');
|
await controllerPage.getByPlaceholder('new name').fill('test');
|
||||||
await controllerPage.getByRole('button', { name: 'Submit' }).click();
|
await controllerPage.getByRole('button', { name: 'Submit' }).click();
|
||||||
await controllerPage.getByTestId('not-self-identify').click();
|
await controllerPage
|
||||||
|
.getByRole('row')
|
||||||
|
.filter({ hasText: /\/timer/ })
|
||||||
|
.getByTestId('not-self-identify')
|
||||||
|
.click();
|
||||||
|
|
||||||
await expect(remotePage.getByTestId('identify-overlay')).toContainText('test');
|
await expect(remotePage.getByTestId('identify-overlay')).toContainText('test');
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user