mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-08 08:53:51 +00:00
refactor(settings): unify empty states, move entity forms to modals, add sidebar search
The settings UI had accumulated three inconsistencies. This addresses all three without renaming any group or breaking `?settings=` deep links. Empty states - Add `Panel.EmptyState` / `Panel.TableEmpty` (title + description + action), promoting the treatment previously local to the spreadsheet PreviewTable. - Add missing empty states to Custom fields, Projects, Rundowns and both Client tables, which previously rendered bare column headers. - Convert the remaining `TableEmpty` call sites off the generic "No data yet". - Rename `.empty` to `.loaderBox` in ProjectPanel, where it meant a loader box rather than an empty state. Entity forms in modals - Add `useEntityModal`, replacing six different open/close mechanisms. - Move the multi-field create/edit forms into `Modal`: automations (wide), triggers, URL presets, custom fields, custom views, new rundown and project merge. Single-field row renames stay inline. - Create and edit now share one surface instead of rendering above the table and in-place respectively, and the list is no longer interactive underneath. - Remove the duplicate project create form; the "New" button now routes to the existing QuickStart modal. Sidebar - Add a search box with per-section keywords, so sections are reachable by the term users have in mind (osc, alias, google sheet, pin...). - Fix duplicated `manage__sheets` id, which highlighted two entries at once. Also - Close button no longer floats over content; drop the 300px padding hack. - Tables scroll with the panel instead of owning a nested scroll area. - Derive divider/table/list padding from a card padding custom property. - Fix an unreachable branch that stopped duplicate custom field labels from being rejected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UP4knuna8kLe79NAuNDhUJ
This commit is contained in:
@@ -1,10 +1,3 @@
|
||||
.corner {
|
||||
position: fixed;
|
||||
top: 6rem;
|
||||
right: 4rem;
|
||||
z-index: $zindex-floating;
|
||||
}
|
||||
|
||||
.contentWrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -14,9 +7,17 @@
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.corner {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 0 1rem 0.5rem;
|
||||
}
|
||||
|
||||
.content {
|
||||
margin: 1rem;
|
||||
margin: 0 1rem 1rem;
|
||||
overflow-y: auto;
|
||||
flex-grow: 1;
|
||||
padding-bottom: 300px;
|
||||
// room for the last section to scroll to the top of the viewport
|
||||
padding-bottom: 40vh;
|
||||
}
|
||||
|
||||
@@ -12,12 +12,12 @@ interface PanelContentProps {
|
||||
export default function PanelContent({ onClose, children }: PropsWithChildren<PanelContentProps>) {
|
||||
return (
|
||||
<div className={style.contentWrapper}>
|
||||
<div className={style.content}>{children}</div>
|
||||
<div className={style.corner}>
|
||||
<Button size='large' onClick={onClose}>
|
||||
Close settings <IoClose />
|
||||
</Button>
|
||||
</div>
|
||||
<div className={style.content}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,13 @@
|
||||
.container {
|
||||
width: min(30vw, 300px);
|
||||
flex: 0 0 min(30vw, 300px);
|
||||
min-width: min(30vw, 300px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.tabs,
|
||||
ul {
|
||||
list-style: none;
|
||||
@@ -6,12 +16,33 @@ ul {
|
||||
}
|
||||
|
||||
.tabs {
|
||||
width: min(30vw, 300px);
|
||||
flex: 0 0 min(30vw, 300px);
|
||||
min-width: min(30vw, 300px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.search {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 0 0 auto;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
.searchIcon {
|
||||
position: absolute;
|
||||
left: 0.5rem;
|
||||
color: $gray-400;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.searchInput {
|
||||
padding-left: 2rem;
|
||||
padding-right: 2rem;
|
||||
}
|
||||
|
||||
.searchClear {
|
||||
position: absolute;
|
||||
right: 0.25rem;
|
||||
}
|
||||
|
||||
.primary,
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { Fragment } from 'react';
|
||||
import { useHotkeys } from '@mantine/hooks';
|
||||
import { Fragment, KeyboardEvent, useMemo, useRef, useState } from 'react';
|
||||
import { IoClose, IoSearch } from 'react-icons/io5';
|
||||
|
||||
import IconButton from '../../../common/components/buttons/IconButton';
|
||||
import Input from '../../../common/components/input/input/Input';
|
||||
import Tooltip from '../../../common/components/tooltip/Tooltip';
|
||||
import { isKeyEnter } from '../../../common/utils/keyEvent';
|
||||
import { cx } from '../../../common/utils/styleUtils';
|
||||
import { SettingsOption, SettingsOptionId, useAppSettingsMenu } from '../useAppSettingsMenu';
|
||||
import * as Panel from '../panel-utils/PanelUtils';
|
||||
import { filterSettingsOptions, SettingsOption, SettingsOptionId, useAppSettingsMenu } from '../useAppSettingsMenu';
|
||||
import useAppSettingsNavigation from '../useAppSettingsNavigation';
|
||||
|
||||
import style from './PanelList.module.scss';
|
||||
@@ -16,23 +21,91 @@ interface PanelListProps extends PanelBaseProps {
|
||||
selectedPanel: string;
|
||||
}
|
||||
|
||||
/** returns the destination a search result should navigate to */
|
||||
function getFirstResultId(results: SettingsOption[]): SettingsOptionId | null {
|
||||
const firstGroup = results[0];
|
||||
if (!firstGroup) {
|
||||
return null;
|
||||
}
|
||||
return (firstGroup.secondary?.[0]?.id ?? firstGroup.id) as SettingsOptionId;
|
||||
}
|
||||
|
||||
export default function PanelList({ selectedPanel, location }: PanelListProps) {
|
||||
const { options } = useAppSettingsMenu();
|
||||
const { setLocation } = useAppSettingsNavigation();
|
||||
const [query, setQuery] = useState('');
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useHotkeys([['mod + f', () => searchRef.current?.focus()]]);
|
||||
|
||||
const results = useMemo(() => filterSettingsOptions(options, query), [options, query]);
|
||||
const isSearching = query.trim().length > 0;
|
||||
|
||||
const handleSearchKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Escape' && isSearching) {
|
||||
// do not let the settings panel close while the user is clearing a search
|
||||
event.stopPropagation();
|
||||
setQuery('');
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Enter') {
|
||||
const target = getFirstResultId(results);
|
||||
if (target) {
|
||||
setLocation(target);
|
||||
setQuery('');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ul className={style.tabs}>
|
||||
{options.map((panel) => {
|
||||
const isSelected = selectedPanel === panel.id;
|
||||
if (panel.highlight) {
|
||||
return (
|
||||
<Tooltip key={panel.id} text={panel.highlight} render={<span />}>
|
||||
<PanelListItem panel={panel} location={location} isSelected={isSelected} />
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
return <PanelListItem key={panel.id} panel={panel} location={location} isSelected={isSelected} />;
|
||||
})}
|
||||
</ul>
|
||||
<div className={style.container}>
|
||||
<div className={style.search}>
|
||||
<IoSearch className={style.searchIcon} />
|
||||
<Input
|
||||
ref={searchRef}
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
placeholder='Search settings'
|
||||
aria-label='Search settings'
|
||||
className={style.searchInput}
|
||||
fluid
|
||||
/>
|
||||
{isSearching && (
|
||||
<IconButton
|
||||
variant='ghosted-white'
|
||||
size='small'
|
||||
aria-label='Clear search'
|
||||
className={style.searchClear}
|
||||
onClick={() => {
|
||||
setQuery('');
|
||||
searchRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
<IoClose />
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{results.length === 0 ? (
|
||||
<Panel.EmptyState title='No settings match' description={`Nothing found for "${query.trim()}"`} />
|
||||
) : (
|
||||
<ul className={style.tabs}>
|
||||
{results.map((panel) => {
|
||||
const isSelected = selectedPanel === panel.id;
|
||||
if (panel.highlight) {
|
||||
return (
|
||||
<Tooltip key={panel.id} text={panel.highlight} render={<span />}>
|
||||
<PanelListItem panel={panel} location={location} isSelected={isSelected} />
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
return <PanelListItem key={panel.id} panel={panel} location={location} isSelected={isSelected} />;
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,24 +1,26 @@
|
||||
$inner-padding: 1rem;
|
||||
$card-padding: 2rem;
|
||||
|
||||
.header {
|
||||
font-size: 2rem;
|
||||
font-size: 1.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid $white-10;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.subheader {
|
||||
font-size: 1.5rem;
|
||||
font-size: 1.125rem;
|
||||
padding-bottom: 0.5rem;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 1.375rem;
|
||||
padding: 0 2rem;
|
||||
font-size: 1rem;
|
||||
padding: 0 var(--panel-card-padding, #{$card-padding});
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -51,8 +53,10 @@ $inner-padding: 1rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
--panel-card-padding: #{$card-padding};
|
||||
|
||||
position: relative;
|
||||
padding: 2rem;
|
||||
padding: var(--panel-card-padding);
|
||||
background-color: $white-3;
|
||||
border: 1px solid $gray-1100;
|
||||
border-radius: 3px;
|
||||
@@ -76,10 +80,10 @@ $inner-padding: 1rem;
|
||||
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 {
|
||||
padding: 0 2rem;
|
||||
max-height: 550px;
|
||||
overflow-y: scroll;
|
||||
padding: 0 var(--panel-card-padding, #{$card-padding});
|
||||
}
|
||||
|
||||
.table {
|
||||
@@ -120,7 +124,7 @@ $inner-padding: 1rem;
|
||||
}
|
||||
|
||||
.listGroup {
|
||||
padding: 0 2rem;
|
||||
padding: 0 var(--panel-card-padding, #{$card-padding});
|
||||
|
||||
> li:not(:last-child) {
|
||||
border-bottom: 1px solid $white-10;
|
||||
@@ -163,7 +167,7 @@ $inner-padding: 1rem;
|
||||
.divider {
|
||||
border: none;
|
||||
border-top: 1px solid $white-10;
|
||||
margin: 1rem -2rem;
|
||||
margin: 1rem calc(var(--panel-card-padding, #{$card-padding}) * -1);
|
||||
}
|
||||
|
||||
.overlay {
|
||||
@@ -188,19 +192,35 @@ $inner-padding: 1rem;
|
||||
animation: animloader 1s ease-in infinite;
|
||||
}
|
||||
|
||||
.empty {
|
||||
background-color: $black-10;
|
||||
.emptyState {
|
||||
padding: 3rem 1.5rem;
|
||||
text-align: center;
|
||||
color: $muted-gray;
|
||||
}
|
||||
|
||||
td {
|
||||
padding-block: 5rem;
|
||||
}
|
||||
.emptyMessage {
|
||||
width: min(30rem, 100%);
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
button {
|
||||
margin-top: 1rem;
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.inlineElements {
|
||||
@@ -239,14 +259,3 @@ $inner-padding: 1rem;
|
||||
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 { IoAdd } from 'react-icons/io5';
|
||||
|
||||
import Button from '../../../common/components/buttons/Button';
|
||||
import { cx } from '../../../common/utils/styleUtils';
|
||||
|
||||
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 (
|
||||
<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}>
|
||||
<div>{label ?? 'No data yet'}</div>
|
||||
{handleClick && (
|
||||
<Button onClick={handleClick} disabled={!handleClick} variant='primary'>
|
||||
New <IoAdd />
|
||||
</Button>
|
||||
)}
|
||||
<EmptyState {...props} />
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
interface EntityModalState<T> {
|
||||
isOpen: boolean;
|
||||
entity: T | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drives a create / edit modal for a list of entities.
|
||||
* Opening with an entity means edit, opening without means create.
|
||||
*
|
||||
* Consumers mount the modal only while `isOpen` is true, which keeps the
|
||||
* form state of the underlying react-hook-form fresh on every open.
|
||||
*/
|
||||
export function useEntityModal<T>() {
|
||||
const [state, setState] = useState<EntityModalState<T>>({ isOpen: false, entity: null });
|
||||
|
||||
const openCreate = useCallback(() => setState({ isOpen: true, entity: null }), []);
|
||||
const openEdit = useCallback((entity: T) => setState({ isOpen: true, entity }), []);
|
||||
const close = useCallback(() => setState({ isOpen: false, entity: null }), []);
|
||||
|
||||
return {
|
||||
isOpen: state.isOpen,
|
||||
entity: state.entity,
|
||||
openCreate,
|
||||
openEdit,
|
||||
close,
|
||||
};
|
||||
}
|
||||
+9
-1
@@ -1,6 +1,14 @@
|
||||
.outerColumn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
font-size: calc(1rem - 1px);
|
||||
color: $ui-white;
|
||||
|
||||
// the wide modal hands us a fixed height area, we own the scroll
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding-block: 0.5rem;
|
||||
|
||||
h3 {
|
||||
font-size: 1rem;
|
||||
|
||||
@@ -19,12 +19,12 @@ import IconButton from '../../../../common/components/buttons/IconButton';
|
||||
import Info from '../../../../common/components/info/Info';
|
||||
import Input from '../../../../common/components/input/input/Input';
|
||||
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 Select from '../../../../common/components/select/Select';
|
||||
import Tag from '../../../../common/components/tag/Tag';
|
||||
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
|
||||
import useCustomFields from '../../../../common/hooks-query/useCustomFields';
|
||||
import { preventEscape } from '../../../../common/utils/keyEvent';
|
||||
import { startsWithHttp } from '../../../../common/utils/regex';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
import { isAutomation, makeFieldList } from './automationUtils';
|
||||
@@ -34,6 +34,7 @@ import TemplateInput from './template-input/TemplateInput';
|
||||
import style from './AutomationForm.module.scss';
|
||||
|
||||
const integrationsDocsUrl = 'https://docs.getontime.no/api/automation/#using-variables-in-automation';
|
||||
const formId = 'automation-form';
|
||||
|
||||
interface AutomationFormProps {
|
||||
automation: Automation | AutomationDTO;
|
||||
@@ -184,15 +185,8 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
|
||||
|
||||
const canSubmit = !isSubmitting && isDirty && isValid;
|
||||
|
||||
return (
|
||||
<Panel.Indent
|
||||
as='form'
|
||||
name='automation-form'
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className={style.outerColumn}
|
||||
onKeyDown={(event) => preventEscape(event, onClose)}
|
||||
>
|
||||
<Panel.SubHeader>{isEdit ? 'Edit automation' : 'Create automation'}</Panel.SubHeader>
|
||||
const bodyElements = (
|
||||
<form id={formId} onSubmit={handleSubmit(onSubmit)} className={style.outerColumn}>
|
||||
<div className={style.innerSection}>
|
||||
<h3>Automation options</h3>
|
||||
<div className={style.titleSection}>
|
||||
@@ -462,14 +456,29 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
|
||||
<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>
|
||||
const 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>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen
|
||||
onClose={onClose}
|
||||
showBackdrop
|
||||
showCloseButton
|
||||
size='wide'
|
||||
title={isEdit ? 'Edit automation' : 'Create automation'}
|
||||
bodyElements={bodyElements}
|
||||
footerElements={footerElements}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AutomationDTO, NormalisedAutomation } from 'ontime-types';
|
||||
import { Automation, AutomationDTO, NormalisedAutomation } from 'ontime-types';
|
||||
import { Fragment, useState } from 'react';
|
||||
import { IoAdd, IoPencil, IoTrash } from 'react-icons/io5';
|
||||
|
||||
@@ -10,6 +10,7 @@ import Info from '../../../../common/components/info/Info';
|
||||
import Tag from '../../../../common/components/tag/Tag';
|
||||
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
import { useEntityModal } from '../../panel-utils/useEntityModal';
|
||||
import AutomationForm from './AutomationForm';
|
||||
|
||||
const automationPlaceholder: AutomationDTO = {
|
||||
@@ -27,7 +28,7 @@ interface AutomationsListProps {
|
||||
export default function AutomationsList(props: AutomationsListProps) {
|
||||
const { automations, enabledAutomations } = props;
|
||||
const { refetch } = useAutomationSettings();
|
||||
const [automationFormData, setAutomationFormData] = useState<AutomationDTO | null>(null);
|
||||
const automationModal = useEntityModal<Automation>();
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
@@ -45,13 +46,12 @@ export default function AutomationsList(props: AutomationsListProps) {
|
||||
|
||||
return (
|
||||
<Panel.Card>
|
||||
{automationModal.isOpen && (
|
||||
<AutomationForm automation={automationModal.entity ?? automationPlaceholder} onClose={automationModal.close} />
|
||||
)}
|
||||
<Panel.SubHeader>
|
||||
Manage automations
|
||||
<Button
|
||||
type='submit'
|
||||
disabled={Boolean(automationFormData)}
|
||||
onClick={() => setAutomationFormData(automationPlaceholder)}
|
||||
>
|
||||
<Button onClick={automationModal.openCreate}>
|
||||
New <IoAdd />
|
||||
</Button>
|
||||
</Panel.SubHeader>
|
||||
@@ -65,10 +65,6 @@ export default function AutomationsList(props: AutomationsListProps) {
|
||||
</Info>
|
||||
)}
|
||||
|
||||
{automationFormData !== null && (
|
||||
<AutomationForm automation={automationFormData} onClose={() => setAutomationFormData(null)} />
|
||||
)}
|
||||
|
||||
<Panel.Table>
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -82,7 +78,13 @@ export default function AutomationsList(props: AutomationsListProps) {
|
||||
<tbody>
|
||||
{arrayAutomations.length === 0 && (
|
||||
<Panel.TableEmpty
|
||||
handleClick={!automationFormData ? () => setAutomationFormData(automationPlaceholder) : undefined}
|
||||
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={automationModal.openCreate}>
|
||||
Create automation <IoAdd />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{arrayAutomations.map((automationId) => {
|
||||
@@ -102,7 +104,7 @@ export default function AutomationsList(props: AutomationsListProps) {
|
||||
<IconButton
|
||||
variant='ghosted-white'
|
||||
aria-label='Edit entry'
|
||||
onClick={() => setAutomationFormData(automations[automationId])}
|
||||
onClick={() => automationModal.openEdit(automations[automationId])}
|
||||
>
|
||||
<IoPencil />
|
||||
</IconButton>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { NormalisedAutomation, TimerLifeCycle, TriggerDTO } from 'ontime-types';
|
||||
import { NormalisedAutomation, TimerLifeCycle, Trigger, TriggerDTO } from 'ontime-types';
|
||||
import { useEffect } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
@@ -6,30 +6,21 @@ import { addTrigger, editTrigger } from '../../../../common/api/automation';
|
||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||
import Button from '../../../../common/components/buttons/Button';
|
||||
import Input from '../../../../common/components/input/input/Input';
|
||||
import Modal from '../../../../common/components/modal/Modal';
|
||||
import Select from '../../../../common/components/select/Select';
|
||||
import { preventEscape } from '../../../../common/utils/keyEvent';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
import { cycles } from './automationUtils';
|
||||
|
||||
const formId = 'trigger-form';
|
||||
|
||||
interface TriggerFormProps {
|
||||
automations: NormalisedAutomation;
|
||||
initialId?: string;
|
||||
initialTitle?: string;
|
||||
initialAutomationId?: string;
|
||||
initialTrigger?: TimerLifeCycle;
|
||||
trigger: Trigger | null;
|
||||
onCancel: () => void;
|
||||
postSubmit: () => void;
|
||||
}
|
||||
|
||||
export default function TriggerForm({
|
||||
automations,
|
||||
initialId,
|
||||
initialTitle,
|
||||
initialAutomationId,
|
||||
initialTrigger,
|
||||
onCancel,
|
||||
postSubmit,
|
||||
}: TriggerFormProps) {
|
||||
export default function TriggerForm({ automations, trigger, onCancel, postSubmit }: TriggerFormProps) {
|
||||
const {
|
||||
handleSubmit,
|
||||
register,
|
||||
@@ -40,9 +31,9 @@ export default function TriggerForm({
|
||||
formState: { errors, isSubmitting, isValid, isDirty },
|
||||
} = useForm<TriggerDTO>({
|
||||
defaultValues: {
|
||||
title: initialTitle,
|
||||
trigger: initialTrigger ?? (cycles[0].value as TimerLifeCycle | undefined),
|
||||
automationId: initialAutomationId ?? automations?.[Object.keys(automations)[0]]?.id,
|
||||
title: trigger?.title,
|
||||
trigger: trigger?.trigger ?? (cycles[0].value as TimerLifeCycle | undefined),
|
||||
automationId: trigger?.automationId ?? automations?.[Object.keys(automations)[0]]?.id,
|
||||
},
|
||||
resetOptions: {
|
||||
keepDirtyValues: true,
|
||||
@@ -55,10 +46,10 @@ export default function TriggerForm({
|
||||
}, [setFocus]);
|
||||
|
||||
const onSubmit = async (values: TriggerDTO) => {
|
||||
// if we were passed an ID we are editing a Trigger
|
||||
if (initialId) {
|
||||
// if we were passed a trigger we are editing it
|
||||
if (trigger) {
|
||||
try {
|
||||
await editTrigger(initialId, { id: initialId, ...values });
|
||||
await editTrigger(trigger.id, { id: trigger.id, ...values });
|
||||
postSubmit();
|
||||
} catch (error) {
|
||||
setError('root', { message: `Failed to save changes to trigger ${maybeAxiosError(error)}` });
|
||||
@@ -84,20 +75,14 @@ export default function TriggerForm({
|
||||
|
||||
const canSubmit = isDirty && isValid;
|
||||
|
||||
return (
|
||||
<Panel.Indent
|
||||
as='form'
|
||||
name='trigger-form'
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
onKeyDown={(event) => preventEscape(event, onCancel)}
|
||||
>
|
||||
<Panel.SubHeader>{initialId ? 'Edit trigger' : 'Create trigger'}</Panel.SubHeader>
|
||||
const bodyElements = (
|
||||
<form id={formId} onSubmit={handleSubmit(onSubmit)}>
|
||||
<label>
|
||||
Title
|
||||
<Input
|
||||
{...register('title', { required: { value: true, message: 'Required field' } })}
|
||||
fluid
|
||||
defaultValue={initialTitle}
|
||||
defaultValue={trigger?.title}
|
||||
/>
|
||||
<Panel.Error>{errors.title?.message}</Panel.Error>
|
||||
</label>
|
||||
@@ -127,14 +112,30 @@ export default function TriggerForm({
|
||||
/>
|
||||
<Panel.Error>{errors.automationId?.message}</Panel.Error>
|
||||
</label>
|
||||
<Panel.InlineElements align='end'>
|
||||
<Button disabled={isSubmitting} onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type='submit' variant='primary' disabled={!canSubmit} loading={isSubmitting}>
|
||||
Save
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
</Panel.Indent>
|
||||
</form>
|
||||
);
|
||||
|
||||
const footerElements = (
|
||||
<>
|
||||
{errors.root && <Panel.Error>{errors.root.message}</Panel.Error>}
|
||||
<Button disabled={isSubmitting} onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type='submit' form={formId} variant='primary' disabled={!canSubmit} loading={isSubmitting}>
|
||||
Save
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen
|
||||
onClose={onCancel}
|
||||
showBackdrop
|
||||
showCloseButton
|
||||
title={trigger ? 'Edit trigger' : 'Create trigger'}
|
||||
bodyElements={bodyElements}
|
||||
footerElements={footerElements}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,8 +8,9 @@ import Button from '../../../../common/components/buttons/Button';
|
||||
import Info from '../../../../common/components/info/Info';
|
||||
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
import { useEntityModal } from '../../panel-utils/useEntityModal';
|
||||
import { checkDuplicates } from './automationUtils';
|
||||
import AutomationForm from './TriggerForm';
|
||||
import TriggerForm from './TriggerForm';
|
||||
import TriggersListItem from './TriggersListItem';
|
||||
|
||||
interface TriggersListProps {
|
||||
@@ -20,7 +21,7 @@ interface TriggersListProps {
|
||||
|
||||
export default function TriggersList(props: TriggersListProps) {
|
||||
const { triggers, automations, enabledAutomations } = props;
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const triggerModal = useEntityModal<Trigger>();
|
||||
const { refetch } = useAutomationSettings();
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
|
||||
@@ -35,7 +36,7 @@ export default function TriggersList(props: TriggersListProps) {
|
||||
};
|
||||
|
||||
const postSubmit = () => {
|
||||
setShowForm(false);
|
||||
triggerModal.close();
|
||||
refetch();
|
||||
};
|
||||
|
||||
@@ -46,9 +47,17 @@ export default function TriggersList(props: TriggersListProps) {
|
||||
|
||||
return (
|
||||
<Panel.Card>
|
||||
{triggerModal.isOpen && (
|
||||
<TriggerForm
|
||||
automations={automations}
|
||||
trigger={triggerModal.entity}
|
||||
onCancel={triggerModal.close}
|
||||
postSubmit={postSubmit}
|
||||
/>
|
||||
)}
|
||||
<Panel.SubHeader>
|
||||
Manage triggers
|
||||
<Button type='submit' form='trigger-form' disabled={!canAdd} loading={false} onClick={() => setShowForm(true)}>
|
||||
<Button disabled={!canAdd} onClick={triggerModal.openCreate}>
|
||||
New <IoAdd />
|
||||
</Button>
|
||||
</Panel.SubHeader>
|
||||
@@ -64,9 +73,6 @@ export default function TriggersList(props: TriggersListProps) {
|
||||
You have created multiple links between the same trigger and automation which can performance issues.
|
||||
</Panel.Error>
|
||||
)}
|
||||
{showForm && (
|
||||
<AutomationForm automations={automations} onCancel={() => setShowForm(false)} postSubmit={postSubmit} />
|
||||
)}
|
||||
<Panel.Table>
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -77,10 +83,21 @@ export default function TriggersList(props: TriggersListProps) {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{!showForm && triggers.length === 0 && (
|
||||
{triggers.length === 0 && (
|
||||
<Panel.TableEmpty
|
||||
label='Create a trigger to run an automation'
|
||||
handleClick={canAdd ? () => setShowForm(true) : undefined}
|
||||
title='No triggers yet'
|
||||
description={
|
||||
canAdd
|
||||
? 'Triggers run an automation at a given point of the timer lifecycle, like when an event starts or finishes.'
|
||||
: 'Create an automation first, then add a trigger to decide when it should run.'
|
||||
}
|
||||
action={
|
||||
canAdd && (
|
||||
<Button variant='primary' onClick={triggerModal.openCreate}>
|
||||
Create trigger <IoAdd />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{triggers.map((trigger, index) => {
|
||||
@@ -88,13 +105,10 @@ export default function TriggersList(props: TriggersListProps) {
|
||||
<Fragment key={trigger.id}>
|
||||
<TriggersListItem
|
||||
automations={automations}
|
||||
id={trigger.id}
|
||||
title={trigger.title}
|
||||
trigger={trigger.trigger}
|
||||
automationId={trigger.automationId}
|
||||
trigger={trigger}
|
||||
duplicate={duplicates?.includes(index)}
|
||||
handleEdit={() => triggerModal.openEdit(trigger)}
|
||||
handleDelete={() => handleDelete(trigger.id)}
|
||||
postSubmit={postSubmit}
|
||||
/>
|
||||
{deleteError && (
|
||||
<tr>
|
||||
|
||||
@@ -1,48 +1,21 @@
|
||||
import { NormalisedAutomation, TimerLifeCycle } from 'ontime-types';
|
||||
import { useState } from 'react';
|
||||
import { NormalisedAutomation, Trigger } from 'ontime-types';
|
||||
import { IoPencil, IoTrash, IoWarningOutline } from 'react-icons/io5';
|
||||
|
||||
import IconButton from '../../../../common/components/buttons/IconButton';
|
||||
import Tag from '../../../../common/components/tag/Tag';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
import { cycles } from './automationUtils';
|
||||
import AutomationForm from './TriggerForm';
|
||||
|
||||
interface TriggersListItemProps {
|
||||
automations: NormalisedAutomation;
|
||||
id: string;
|
||||
title: string;
|
||||
trigger: TimerLifeCycle;
|
||||
automationId: string;
|
||||
trigger: Trigger;
|
||||
duplicate?: boolean;
|
||||
handleEdit: () => void;
|
||||
handleDelete: () => void;
|
||||
postSubmit: () => void;
|
||||
}
|
||||
|
||||
export default function TriggersListItem(props: TriggersListItemProps) {
|
||||
const { automations, id, title, trigger, automationId, duplicate, handleDelete, postSubmit } = 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>
|
||||
);
|
||||
}
|
||||
const { automations, trigger, duplicate, handleEdit, handleDelete } = props;
|
||||
|
||||
return (
|
||||
<tr data-warn={duplicate}>
|
||||
@@ -52,16 +25,16 @@ export default function TriggersListItem(props: TriggersListItemProps) {
|
||||
color='#FFBC56' // $orange-500
|
||||
/>
|
||||
)}
|
||||
{title}
|
||||
{trigger.title}
|
||||
</Panel.InlineElements>
|
||||
<td>
|
||||
<Tag>{cycles.find((cycle) => cycle.value === trigger)?.label}</Tag>
|
||||
<Tag>{cycles.find((cycle) => cycle.value === trigger.trigger)?.label}</Tag>
|
||||
</td>
|
||||
<td>
|
||||
<Tag>{automations?.[automationId]?.title}</Tag>
|
||||
<Tag>{automations?.[trigger.automationId]?.title}</Tag>
|
||||
</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 />
|
||||
</IconButton>
|
||||
<IconButton variant='ghosted-destructive' aria-label='Delete entry' onClick={handleDelete}>
|
||||
|
||||
@@ -66,7 +66,10 @@ export default function ReportSettings() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{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) => {
|
||||
|
||||
@@ -12,25 +12,17 @@ import Tag from '../../../../common/components/tag/Tag';
|
||||
import useUrlPresets, { useUpdateUrlPreset } from '../../../../common/hooks-query/useUrlPresets';
|
||||
import { handleLinks } from '../../../../common/utils/linkUtils';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
import { useEntityModal } from '../../panel-utils/useEntityModal';
|
||||
import URLPresetForm from './composite/URLPresetForm';
|
||||
|
||||
type FormState = {
|
||||
isOpen: boolean;
|
||||
preset?: URLPreset;
|
||||
};
|
||||
|
||||
const urlPresetsDocs = 'https://docs.getontime.no/features/url-presets/';
|
||||
|
||||
export default function URLPresets() {
|
||||
const [formState, setFormState] = useState<FormState>({ isOpen: false, preset: undefined });
|
||||
const presetModal = useEntityModal<URLPreset>();
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const { data, status } = useUrlPresets();
|
||||
const { updatePreset, deletePreset, isMutating } = useUpdateUrlPreset();
|
||||
|
||||
const openNewForm = () => setFormState({ isOpen: true });
|
||||
const openEditForm = (preset: URLPreset) => setFormState({ isOpen: true, preset });
|
||||
const closeForm = () => setFormState({ isOpen: false, preset: undefined });
|
||||
|
||||
const persistPreset = async (preset: URLPreset) => {
|
||||
setActionError(null);
|
||||
try {
|
||||
@@ -43,9 +35,12 @@ export default function URLPresets() {
|
||||
return (
|
||||
<Panel.Section>
|
||||
<Panel.Card>
|
||||
{presetModal.isOpen && (
|
||||
<URLPresetForm urlPreset={presetModal.entity ?? undefined} onClose={presetModal.close} />
|
||||
)}
|
||||
<Panel.SubHeader>
|
||||
URL presets
|
||||
<Button onClick={openNewForm}>
|
||||
<Button onClick={presetModal.openCreate}>
|
||||
New <IoAdd />
|
||||
</Button>
|
||||
</Panel.SubHeader>
|
||||
@@ -63,7 +58,6 @@ export default function URLPresets() {
|
||||
</Panel.Section>
|
||||
<Panel.Section>
|
||||
<Panel.Loader isLoading={status === 'pending'} />
|
||||
{formState.isOpen && <URLPresetForm urlPreset={formState.preset} onClose={closeForm} />}
|
||||
{actionError && <Panel.Error>{actionError}</Panel.Error>}
|
||||
<Panel.Table>
|
||||
<thead>
|
||||
@@ -76,7 +70,17 @@ export default function URLPresets() {
|
||||
</tr>
|
||||
</thead>
|
||||
<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={presetModal.openCreate}>
|
||||
Create preset <IoAdd />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{data.map((preset, index) => {
|
||||
const isCuesheet = preset.target === OntimeView.Cuesheet;
|
||||
return (
|
||||
@@ -109,7 +113,7 @@ export default function URLPresets() {
|
||||
<IoOpenOutline />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
onClick={() => openEditForm(preset)}
|
||||
onClick={() => presetModal.openEdit(preset)}
|
||||
variant='ghosted-white'
|
||||
aria-label='Edit entry'
|
||||
data-testid={`field__edit_${index}`}
|
||||
|
||||
+34
-23
@@ -6,9 +6,9 @@ import { maybeAxiosError, unwrapError } from '../../../../../common/api/utils';
|
||||
import Button from '../../../../../common/components/buttons/Button';
|
||||
import Input from '../../../../../common/components/input/input/Input';
|
||||
import Textarea from '../../../../../common/components/input/textarea/Textarea';
|
||||
import Modal from '../../../../../common/components/modal/Modal';
|
||||
import Select, { SelectOption } from '../../../../../common/components/select/Select';
|
||||
import { useUpdateUrlPreset } from '../../../../../common/hooks-query/useUrlPresets';
|
||||
import { preventEscape } from '../../../../../common/utils/keyEvent';
|
||||
import { isUrlSafe } from '../../../../../common/utils/regex';
|
||||
import { enDash } from '../../../../../common/utils/styleUtils';
|
||||
import { generateUrlPresetOptions } from '../../../../../common/utils/urlPresets';
|
||||
@@ -28,6 +28,8 @@ const targetOptions: SelectOption<OntimeViewPresettable>[] = [
|
||||
{ value: OntimeView.ProjectInfo, label: 'Project Info' },
|
||||
];
|
||||
|
||||
const formId = 'url-preset-form';
|
||||
|
||||
const defaultValues: URLPreset = {
|
||||
alias: '',
|
||||
target: OntimeView.Timer,
|
||||
@@ -133,13 +135,8 @@ export default function URLPresetForm({ urlPreset, onClose }: URLPresetFormProps
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Panel.Indent
|
||||
as='form'
|
||||
onSubmit={handleSubmit(setupSubmit)}
|
||||
onKeyDown={(event) => preventEscape(event, onClose)}
|
||||
className={style.column}
|
||||
>
|
||||
const 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>
|
||||
@@ -209,20 +206,34 @@ export default function URLPresetForm({ urlPreset, onClose }: URLPresetFormProps
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<Panel.Error>{errors.root?.message}</Panel.Error>
|
||||
<Panel.InlineElements align='end'>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
<Button
|
||||
variant='primary'
|
||||
type='submit'
|
||||
disabled={!isValid || (!isDirty && !permissionsDirty) || noReadAccess}
|
||||
loading={isSubmitting || isMutating}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
</div>
|
||||
</Panel.Indent>
|
||||
</form>
|
||||
);
|
||||
|
||||
const footerElements = (
|
||||
<>
|
||||
<Panel.Error>{errors.root?.message}</Panel.Error>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
<Button
|
||||
variant='primary'
|
||||
type='submit'
|
||||
form={formId}
|
||||
disabled={!isValid || (!isDirty && !permissionsDirty) || noReadAccess}
|
||||
loading={isSubmitting || isMutating}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen
|
||||
onClose={onClose}
|
||||
showBackdrop
|
||||
showCloseButton
|
||||
title={urlPreset ? 'Edit URL preset' : 'Create URL preset'}
|
||||
bodyElements={bodyElements}
|
||||
footerElements={footerElements}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { CustomField, CustomFieldKey } from 'ontime-types';
|
||||
import { useState } from 'react';
|
||||
import { IoAdd } from 'react-icons/io5';
|
||||
|
||||
import { deleteCustomField, editCustomField, postCustomField } from '../../../../common/api/customFields';
|
||||
@@ -10,30 +9,25 @@ import ExternalLink from '../../../../common/components/link/external-link/Exter
|
||||
import useCustomFields from '../../../../common/hooks-query/useCustomFields';
|
||||
import { customFieldsDocsUrl } from '../../../../externals';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
import { useEntityModal } from '../../panel-utils/useEntityModal';
|
||||
import CustomFieldEntry from './composite/CustomFieldEntry';
|
||||
import CustomFieldForm from './composite/CustomFieldForm';
|
||||
|
||||
type CustomFieldEntity = CustomField & { key: CustomFieldKey };
|
||||
|
||||
export default function CustomFieldSettings() {
|
||||
const { data, refetch } = useCustomFields();
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
const fieldModal = useEntityModal<CustomFieldEntity>();
|
||||
|
||||
const handleInitiateCreate = () => {
|
||||
setIsAdding(true);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setIsAdding(false);
|
||||
};
|
||||
|
||||
const handleCreate = async (customField: CustomField) => {
|
||||
await postCustomField(customField);
|
||||
refetch();
|
||||
setIsAdding(false);
|
||||
};
|
||||
|
||||
const handleEditField = async (key: CustomFieldKey, customField: CustomField) => {
|
||||
await editCustomField(key, customField);
|
||||
const handleSubmit = async (customField: CustomField) => {
|
||||
const editing = fieldModal.entity;
|
||||
if (editing) {
|
||||
await editCustomField(editing.key, customField);
|
||||
} else {
|
||||
await postCustomField(customField);
|
||||
}
|
||||
refetch();
|
||||
fieldModal.close();
|
||||
};
|
||||
|
||||
const handleDelete = async (key: CustomFieldKey) => {
|
||||
@@ -45,12 +39,24 @@ export default function CustomFieldSettings() {
|
||||
}
|
||||
};
|
||||
|
||||
const entries = Object.entries(data);
|
||||
|
||||
return (
|
||||
<Panel.Section>
|
||||
<Panel.Card>
|
||||
{fieldModal.isOpen && (
|
||||
<CustomFieldForm
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={fieldModal.close}
|
||||
initialColour={fieldModal.entity?.colour}
|
||||
initialLabel={fieldModal.entity?.label}
|
||||
initialKey={fieldModal.entity?.key}
|
||||
initialType={fieldModal.entity?.type}
|
||||
/>
|
||||
)}
|
||||
<Panel.SubHeader>
|
||||
Custom fields
|
||||
<Button onClick={handleInitiateCreate}>
|
||||
<Button onClick={fieldModal.openCreate}>
|
||||
New <IoAdd />
|
||||
</Button>
|
||||
</Panel.SubHeader>
|
||||
@@ -66,7 +72,6 @@ export default function CustomFieldSettings() {
|
||||
</Info>
|
||||
</Panel.Section>
|
||||
<Panel.Section>
|
||||
{isAdding && <CustomFieldForm onSubmit={handleCreate} onCancel={handleCancel} />}
|
||||
<Panel.Table>
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -78,7 +83,18 @@ export default function CustomFieldSettings() {
|
||||
</tr>
|
||||
</thead>
|
||||
<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={fieldModal.openCreate}>
|
||||
Create custom field <IoAdd />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{entries.map(([key, { colour, label, type }]) => {
|
||||
return (
|
||||
<CustomFieldEntry
|
||||
key={key}
|
||||
@@ -86,7 +102,7 @@ export default function CustomFieldSettings() {
|
||||
colour={colour}
|
||||
label={label}
|
||||
type={type}
|
||||
onEdit={handleEditField}
|
||||
onEdit={() => fieldModal.openEdit({ key, colour, label, type })}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -5,11 +5,14 @@ import { uploadCustomView } from '../../../../common/api/customViews';
|
||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||
import Button from '../../../../common/components/buttons/Button';
|
||||
import Input from '../../../../common/components/input/input/Input';
|
||||
import Modal from '../../../../common/components/modal/Modal';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
import { getFileError, getSlugError, getViewUrl, maxUploadLabel } from './customViews.utils';
|
||||
|
||||
import style from './CustomViews.module.scss';
|
||||
|
||||
const formId = 'custom-view-form';
|
||||
|
||||
interface CustomViewFormProps {
|
||||
onComplete: () => void;
|
||||
onClose: () => void;
|
||||
@@ -52,8 +55,8 @@ export default function CustomViewForm({ onComplete, onClose }: CustomViewFormPr
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Panel.Indent as='form' onSubmit={handleUpload} className={style.uploadForm}>
|
||||
const bodyElements = (
|
||||
<form id={formId} onSubmit={handleUpload} className={style.uploadForm}>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
style={{ display: 'none' }}
|
||||
@@ -100,15 +103,28 @@ export default function CustomViewForm({ onComplete, onClose }: CustomViewFormPr
|
||||
<Panel.Description>Accepted: index.html only, maximum {maxUploadLabel}.</Panel.Description>
|
||||
{fileDirty && fileError && <Panel.Error>{fileError}</Panel.Error>}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
|
||||
const 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>
|
||||
</>
|
||||
);
|
||||
|
||||
<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>
|
||||
return (
|
||||
<Modal
|
||||
isOpen
|
||||
onClose={onClose}
|
||||
showBackdrop
|
||||
showCloseButton
|
||||
title='Upload custom view'
|
||||
bodyElements={bodyElements}
|
||||
footerElements={footerElements}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -37,13 +37,14 @@ export default function CustomViews() {
|
||||
return (
|
||||
<Panel.Section>
|
||||
<Panel.Card>
|
||||
{isUploadOpen && <CustomViewForm onComplete={handleUploadComplete} onClose={() => setIsUploadOpen(false)} />}
|
||||
<Panel.SubHeader>
|
||||
Custom views
|
||||
<Panel.InlineElements>
|
||||
<Button variant='ghosted' onClick={handleRestoreDemo} disabled={hasDemoView}>
|
||||
Restore demo
|
||||
</Button>
|
||||
<Button onClick={() => setIsUploadOpen(true)} disabled={isUploadOpen}>
|
||||
<Button onClick={() => setIsUploadOpen(true)}>
|
||||
New <IoAdd />
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
@@ -63,8 +64,6 @@ export default function CustomViews() {
|
||||
<Panel.Section>
|
||||
<Panel.Loader isLoading={status === 'pending'} />
|
||||
|
||||
{isUploadOpen && <CustomViewForm onComplete={handleUploadComplete} onClose={() => setIsUploadOpen(false)} />}
|
||||
|
||||
{actionError && <Panel.Error>{actionError}</Panel.Error>}
|
||||
|
||||
<CustomViewsList
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { CustomViewSummary } from 'ontime-types';
|
||||
import { useState } from 'react';
|
||||
import { IoCloudUploadOutline } from 'react-icons/io5';
|
||||
|
||||
import { deleteCustomView } from '../../../../common/api/customViews';
|
||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||
@@ -53,7 +54,17 @@ export default function CustomViewsList({ views, onOpenUpload, onMutate, onError
|
||||
</tr>
|
||||
</thead>
|
||||
<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) => (
|
||||
<CustomViewsListItem
|
||||
key={view.slug}
|
||||
|
||||
@@ -12,6 +12,12 @@
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.formColumn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.current {
|
||||
background-color: $blue-1100 !important; // fighting zebra styles
|
||||
}
|
||||
|
||||
@@ -3,9 +3,12 @@ import { useForm } from 'react-hook-form';
|
||||
|
||||
import Button from '../../../../common/components/buttons/Button';
|
||||
import Input from '../../../../common/components/input/input/Input';
|
||||
import Modal from '../../../../common/components/modal/Modal';
|
||||
import { useMutateProjectRundowns } from '../../../../common/hooks-query/useProjectRundowns';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
const formId = 'new-rundown-form';
|
||||
|
||||
type NewRundownFormState = {
|
||||
title: string;
|
||||
};
|
||||
@@ -41,23 +44,38 @@ export function ManageRundownForm({ onClose }: ManageRundownForm) {
|
||||
setFocus('title');
|
||||
}, [setFocus]);
|
||||
|
||||
return (
|
||||
<Panel.Indent as='form' onSubmit={handleSubmit(createRundown)}>
|
||||
const bodyElements = (
|
||||
<form id={formId} onSubmit={handleSubmit(createRundown)}>
|
||||
<Panel.Section>
|
||||
<label>
|
||||
<Panel.Description>Rundown title</Panel.Description>
|
||||
<Input {...register('title')} fluid placeholder='Your rundown name' />
|
||||
</label>
|
||||
</Panel.Section>
|
||||
<Panel.InlineElements relation='inner' align='end'>
|
||||
<Button variant='ghosted' disabled={isSubmitting} onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type='submit' variant='primary' disabled={isSubmitting}>
|
||||
Create rundown
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
</form>
|
||||
);
|
||||
|
||||
const footerElements = (
|
||||
<>
|
||||
{errors.root && <Panel.Error>{errors.root.message}</Panel.Error>}
|
||||
</Panel.Indent>
|
||||
<Button variant='ghosted' disabled={isSubmitting} onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type='submit' form={formId} variant='primary' disabled={isSubmitting}>
|
||||
Create rundown
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen
|
||||
onClose={onClose}
|
||||
showBackdrop
|
||||
showCloseButton
|
||||
title='Create rundown'
|
||||
bodyElements={bodyElements}
|
||||
footerElements={footerElements}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -131,6 +131,23 @@ export default function ManageRundowns() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data?.rundowns?.length === 0 && (
|
||||
<Panel.TableEmpty
|
||||
title='No rundowns yet'
|
||||
description='A project can hold several rundowns. Create one to start building your show.'
|
||||
action={
|
||||
<Button
|
||||
variant='primary'
|
||||
onClick={() => {
|
||||
setActionError(null);
|
||||
newHandlers.open();
|
||||
}}
|
||||
>
|
||||
Create rundown <IoAdd />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{data?.rundowns?.map(({ id, numEntries, title }) => {
|
||||
const isLoaded = data.loaded === id;
|
||||
const isRenaming = renamingRundown === id;
|
||||
|
||||
+2
-27
@@ -1,5 +1,4 @@
|
||||
import { CustomField, CustomFieldKey } from 'ontime-types';
|
||||
import { useState } from 'react';
|
||||
import { IoPencil, IoTrash } from 'react-icons/io5';
|
||||
|
||||
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 Tag from '../../../../../common/components/tag/Tag';
|
||||
import * as Panel from '../../../panel-utils/PanelUtils';
|
||||
import CustomFieldForm from './CustomFieldForm';
|
||||
|
||||
import style from '../ManagePanel.module.scss';
|
||||
|
||||
@@ -16,35 +14,12 @@ interface CustomFieldEntryProps {
|
||||
label: string;
|
||||
fieldKey: string;
|
||||
type: CustomField['type'];
|
||||
onEdit: (key: CustomFieldKey, patch: CustomField) => Promise<void>;
|
||||
onEdit: () => void;
|
||||
onDelete: (key: CustomFieldKey) => Promise<void>;
|
||||
}
|
||||
|
||||
export default function CustomFieldEntry(props: CustomFieldEntryProps) {
|
||||
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 (
|
||||
<tr>
|
||||
@@ -61,7 +36,7 @@ export default function CustomFieldEntry(props: CustomFieldEntryProps) {
|
||||
</CopyTag>
|
||||
</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 />
|
||||
</IconButton>
|
||||
<IconButton variant='ghosted-destructive' aria-label='Delete entry' onClick={() => onDelete(fieldKey)}>
|
||||
|
||||
+31
-14
@@ -8,13 +8,15 @@ import Button from '../../../../../common/components/buttons/Button';
|
||||
import Info from '../../../../../common/components/info/Info';
|
||||
import SwatchSelect from '../../../../../common/components/input/colour-input/SwatchSelect';
|
||||
import Input from '../../../../../common/components/input/input/Input';
|
||||
import Modal from '../../../../../common/components/modal/Modal';
|
||||
import RadioGroup from '../../../../../common/components/radio-group/RadioGroup';
|
||||
import useCustomFields from '../../../../../common/hooks-query/useCustomFields';
|
||||
import { preventEscape } from '../../../../../common/utils/keyEvent';
|
||||
import * as Panel from '../../../panel-utils/PanelUtils';
|
||||
|
||||
import style from '../ManagePanel.module.scss';
|
||||
|
||||
const formId = 'custom-field-form';
|
||||
|
||||
interface CustomFieldsFormProps {
|
||||
onSubmit: (field: CustomField) => Promise<void>;
|
||||
onCancel: () => void;
|
||||
@@ -84,8 +86,8 @@ export default function CustomFieldForm({
|
||||
// if initial values are given, we can assume we are in edit mode
|
||||
const isEditMode = initialKey !== undefined;
|
||||
|
||||
return (
|
||||
<Panel.Indent as='form' onSubmit={handleSubmit(setupSubmit)} onKeyDown={(event) => preventEscape(event, onCancel)}>
|
||||
const bodyElements = (
|
||||
<form id={formId} onSubmit={handleSubmit(setupSubmit)} className={style.formColumn}>
|
||||
<Info>
|
||||
Please note that images can quickly deteriorate your app's performance.
|
||||
<br />
|
||||
@@ -116,8 +118,8 @@ export default function CustomFieldForm({
|
||||
if (value.trim().length === 0) return 'Required field';
|
||||
if (!checkRegex.isAlphanumericWithSpace(value))
|
||||
return 'Only alphanumeric characters and space are allowed';
|
||||
if (!isEditMode) {
|
||||
if (isEditMode && Object.keys(data).includes(value)) return 'Custom fields must be unique';
|
||||
if (!isEditMode && Object.hasOwn(data, customFieldLabelToKey(value) ?? '')) {
|
||||
return 'Custom fields must be unique';
|
||||
}
|
||||
return true;
|
||||
},
|
||||
@@ -135,15 +137,30 @@ export default function CustomFieldForm({
|
||||
<Panel.Description>Colour</Panel.Description>
|
||||
<SwatchSelect name='colour' value={colour} handleChange={(_field, value) => handleSelectColour(value)} />
|
||||
</label>
|
||||
</form>
|
||||
);
|
||||
|
||||
const footerElements = (
|
||||
<>
|
||||
{errors.root && <Panel.Error>{errors.root.message}</Panel.Error>}
|
||||
<Panel.InlineElements relation='inner' align='end'>
|
||||
<Button variant='ghosted' onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type='submit' variant='primary' disabled={!canSubmit} loading={isSubmitting}>
|
||||
Save
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
</Panel.Indent>
|
||||
<Button variant='ghosted' onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type='submit' form={formId} variant='primary' disabled={!canSubmit} loading={isSubmitting}>
|
||||
Save
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen
|
||||
onClose={onCancel}
|
||||
showBackdrop
|
||||
showCloseButton
|
||||
title={isEditMode ? 'Edit custom field' : 'Create custom field'}
|
||||
bodyElements={bodyElements}
|
||||
footerElements={footerElements}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
-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 {
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
|
||||
+10
-15
@@ -6,6 +6,7 @@ import { useMemo } from 'react';
|
||||
import Button from '../../../../../../../common/components/buttons/Button';
|
||||
import Tag from '../../../../../../../common/components/tag/Tag';
|
||||
import { getRundownMetadata } from '../../../../../../../common/utils/rundownMetadata';
|
||||
import * as Panel from '../../../../../panel-utils/PanelUtils';
|
||||
import { getCellValue } from './previewTableUtils';
|
||||
|
||||
import style from './PreviewTable.module.scss';
|
||||
@@ -123,23 +124,17 @@ export default function PreviewTable({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={style.emptyState}>
|
||||
<div className={style.emptyMessage}>
|
||||
<div className={style.emptyTitle}>{emptyTitle}</div>
|
||||
<div className={style.emptyBody}>{emptyContent}</div>
|
||||
{needsPreviewRefresh && (
|
||||
<Button
|
||||
className={style.emptyAction}
|
||||
variant='primary'
|
||||
onClick={onRefresh}
|
||||
disabled={!canRefresh}
|
||||
loading={isRefreshing}
|
||||
>
|
||||
<Panel.EmptyState
|
||||
title={emptyTitle}
|
||||
description={emptyContent}
|
||||
action={
|
||||
needsPreviewRefresh && (
|
||||
<Button variant='primary' onClick={onRefresh} disabled={!canRefresh} loading={isRefreshing}>
|
||||
Refresh preview
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+12
@@ -68,6 +68,12 @@ export default function ClientList() {
|
||||
</tr>
|
||||
</thead>
|
||||
<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]) => {
|
||||
const { identify, name, path } = client;
|
||||
const isCurrent = id === key;
|
||||
@@ -125,6 +131,12 @@ export default function ClientList() {
|
||||
</tr>
|
||||
</thead>
|
||||
<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]) => {
|
||||
const { name, type } = client;
|
||||
|
||||
|
||||
@@ -1,29 +1,21 @@
|
||||
import { ChangeEvent, useRef, useState } from 'react';
|
||||
import { IoAdd } from 'react-icons/io5';
|
||||
import { useSearchParams } from 'react-router';
|
||||
|
||||
import { uploadProjectFile } from '../../../../common/api/db';
|
||||
import { invalidateAllCaches, maybeAxiosError } from '../../../../common/api/utils';
|
||||
import Button from '../../../../common/components/buttons/Button';
|
||||
import { validateProjectFile } from '../../../../common/utils/uploadUtils';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
import ProjectCreateForm from './ProjectCreateForm';
|
||||
import useAppSettingsNavigation from '../../useAppSettingsNavigation';
|
||||
import ProjectList from './ProjectList';
|
||||
|
||||
export default function ManageProjects() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { setLocation } = useAppSettingsNavigation();
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState<'import' | null>(null);
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const isCreatingProject = searchParams.get('new') === 'true';
|
||||
|
||||
const handleToggleCreate = () => {
|
||||
searchParams.set('new', isCreatingProject ? 'false' : 'true');
|
||||
setSearchParams(searchParams);
|
||||
};
|
||||
|
||||
const handleSelectFile = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
@@ -49,11 +41,6 @@ export default function ManageProjects() {
|
||||
setLoading(null);
|
||||
};
|
||||
|
||||
const handleCloseForm = () => {
|
||||
searchParams.delete('new');
|
||||
setSearchParams(searchParams);
|
||||
};
|
||||
|
||||
return (
|
||||
<Panel.Section>
|
||||
<input
|
||||
@@ -68,21 +55,16 @@ export default function ManageProjects() {
|
||||
<Panel.SubHeader>
|
||||
Manage projects
|
||||
<Panel.InlineElements>
|
||||
<Button
|
||||
onClick={handleSelectFile}
|
||||
disabled={Boolean(loading) || isCreatingProject}
|
||||
loading={loading === 'import'}
|
||||
>
|
||||
<Button onClick={handleSelectFile} disabled={Boolean(loading)} loading={loading === 'import'}>
|
||||
Import
|
||||
</Button>
|
||||
<Button onClick={handleToggleCreate} disabled={Boolean(loading) || isCreatingProject}>
|
||||
<Button onClick={() => setLocation('project__create')} disabled={Boolean(loading)}>
|
||||
New <IoAdd />
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
</Panel.SubHeader>
|
||||
{error && <Panel.Error>{error}</Panel.Error>}
|
||||
<Panel.Divider />
|
||||
{isCreatingProject && <ProjectCreateForm onClose={handleCloseForm} />}
|
||||
<ProjectList />
|
||||
</Panel.Card>
|
||||
</Panel.Section>
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
import { createProject } from '../../../../common/api/db';
|
||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||
import Button from '../../../../common/components/buttons/Button';
|
||||
import Input from '../../../../common/components/input/input/Input';
|
||||
import { preventEscape } from '../../../../common/utils/keyEvent';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import style from './ProjectPanel.module.scss';
|
||||
|
||||
interface ProjectCreateFromProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type ProjectCreateFormValues = {
|
||||
title?: string;
|
||||
description?: string;
|
||||
info?: string;
|
||||
url?: string;
|
||||
custom?: { title: string; value: string }[];
|
||||
};
|
||||
|
||||
export default function ProjectCreateForm({ onClose }: ProjectCreateFromProps) {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
register,
|
||||
formState: { isSubmitting, isValid },
|
||||
setFocus,
|
||||
} = useForm<ProjectCreateFormValues>({
|
||||
defaultValues: { title: '' },
|
||||
values: { title: '' },
|
||||
resetOptions: {
|
||||
keepDirtyValues: true,
|
||||
},
|
||||
});
|
||||
|
||||
// set focus to first field
|
||||
useEffect(() => {
|
||||
setFocus('title');
|
||||
}, [setFocus]);
|
||||
|
||||
const handleSubmitCreate = async (values: ProjectCreateFormValues) => {
|
||||
try {
|
||||
setError(null);
|
||||
|
||||
const filename = values.title ?? 'untitled';
|
||||
|
||||
await createProject({ filename });
|
||||
|
||||
onClose();
|
||||
} catch (error) {
|
||||
setError(maybeAxiosError(error));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Panel.Indent
|
||||
as='form'
|
||||
onSubmit={handleSubmit(handleSubmitCreate)}
|
||||
onKeyDown={(event) => preventEscape(event, onClose)}
|
||||
>
|
||||
<Panel.Title>
|
||||
Create new project
|
||||
<Panel.InlineElements>
|
||||
<Button onClick={onClose} variant='ghosted' disabled={isSubmitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button disabled={!isValid} type='submit' loading={isSubmitting} variant='primary'>
|
||||
Create project
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
</Panel.Title>
|
||||
{error && <Panel.Error>{error}</Panel.Error>}
|
||||
<Panel.Section className={style.innerColumn}>
|
||||
<Panel.Description>Project title</Panel.Description>
|
||||
<Input fluid placeholder='Your project name' {...register('title')} />
|
||||
</Panel.Section>
|
||||
</Panel.Indent>
|
||||
);
|
||||
}
|
||||
@@ -40,7 +40,7 @@ export default function ProjectList() {
|
||||
|
||||
if (status === 'pending') {
|
||||
return (
|
||||
<div className={style.empty}>
|
||||
<div className={style.loaderBox}>
|
||||
<Panel.Loader isLoading />
|
||||
</div>
|
||||
);
|
||||
@@ -74,6 +74,12 @@ export default function ProjectList() {
|
||||
</tr>
|
||||
</thead>
|
||||
<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) => (
|
||||
<ProjectListItem
|
||||
key={project.filename}
|
||||
|
||||
@@ -158,13 +158,7 @@ export default function ProjectListItem({
|
||||
</>
|
||||
)}
|
||||
</tr>
|
||||
{showMergeForm && (
|
||||
<tr>
|
||||
<td colSpan={99}>
|
||||
<ProjectMergeForm onClose={handleCancel} fileName={filename} />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{showMergeForm && <ProjectMergeForm onClose={handleCancel} fileName={filename} />}
|
||||
<Dialog
|
||||
isOpen={isDeleteOpen}
|
||||
onClose={() => setDeleteOpen(false)}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { getDb, patchData } from '../../../../common/api/db';
|
||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||
import Button from '../../../../common/components/buttons/Button';
|
||||
import Info from '../../../../common/components/info/Info';
|
||||
import Modal from '../../../../common/components/modal/Modal';
|
||||
import Switch from '../../../../common/components/switch/Switch';
|
||||
import { cx } from '../../../../common/utils/styleUtils';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
@@ -14,6 +15,8 @@ import { makeProjectPatch } from './project.utils';
|
||||
|
||||
import style from './ProjectPanel.module.scss';
|
||||
|
||||
const formId = 'project-merge-form';
|
||||
|
||||
interface ProjectMergeFromProps {
|
||||
onClose: () => void;
|
||||
fileName: string;
|
||||
@@ -76,20 +79,8 @@ export default function ProjectMergeForm({ onClose, fileName }: ProjectMergeFrom
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Panel.Section as='form' onSubmit={handleSubmit(handleSubmitCreate)}>
|
||||
<Panel.Title>
|
||||
Partial project merge
|
||||
<Panel.InlineElements>
|
||||
<Button onClick={onClose} variant='ghosted' disabled={isSubmitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type='submit' disabled={!isValid || !isDirty} loading={isSubmitting} variant='primary'>
|
||||
Merge
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
</Panel.Title>
|
||||
{error && <Panel.Error>{error}</Panel.Error>}
|
||||
const bodyElements = (
|
||||
<form id={formId} onSubmit={handleSubmit(handleSubmitCreate)}>
|
||||
<Panel.Section className={cx([style.innerColumn, style.inlineLabels])}>
|
||||
<Panel.Description>
|
||||
Select data from <i>{`"${fileName}"`}</i> to merge into the current project.
|
||||
@@ -139,6 +130,30 @@ export default function ProjectMergeForm({ onClose, fileName }: ProjectMergeFrom
|
||||
Automation Settings
|
||||
</label>
|
||||
</Panel.Section>
|
||||
</Panel.Section>
|
||||
</form>
|
||||
);
|
||||
|
||||
const footerElements = (
|
||||
<>
|
||||
{error && <Panel.Error>{error}</Panel.Error>}
|
||||
<Button onClick={onClose} variant='ghosted' disabled={isSubmitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type='submit' form={formId} disabled={!isValid || !isDirty} loading={isSubmitting} variant='primary'>
|
||||
Merge
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen
|
||||
onClose={onClose}
|
||||
showBackdrop
|
||||
showCloseButton
|
||||
title='Partial project merge'
|
||||
bodyElements={bodyElements}
|
||||
footerElements={footerElements}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.empty {
|
||||
.loaderBox {
|
||||
height: 300px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ export type SettingsOption = {
|
||||
label: string;
|
||||
secondary?: Readonly<SettingsOption[]>;
|
||||
highlight?: string;
|
||||
/** alternative terms a user may search for when looking for this section */
|
||||
keywords?: Readonly<string[]>;
|
||||
};
|
||||
|
||||
const staticOptions = [
|
||||
@@ -15,52 +17,94 @@ const staticOptions = [
|
||||
id: 'settings',
|
||||
label: 'Settings',
|
||||
secondary: [
|
||||
{ id: 'settings__data', label: 'Project data' },
|
||||
{ id: 'settings__general', label: 'General settings' },
|
||||
{ id: 'settings__view', label: 'View settings' },
|
||||
{ id: 'settings__custom-views', label: 'Custom views' },
|
||||
{ id: 'settings__mcp', label: 'MCP Server' },
|
||||
...(isDocker ? [] : [{ id: 'settings__port', label: 'Server port' }]),
|
||||
{ id: 'settings__data', label: 'Project data', keywords: ['title', 'description', 'logo', 'url', 'info'] },
|
||||
{
|
||||
id: 'settings__general',
|
||||
label: 'General settings',
|
||||
keywords: ['pin', 'password', 'lock', 'language', 'time format', 'timezone', '12 hour', '24 hour'],
|
||||
},
|
||||
{
|
||||
id: 'settings__view',
|
||||
label: 'View settings',
|
||||
keywords: ['css', 'style', 'theme', 'override', 'translation', 'freeze', 'overtime'],
|
||||
},
|
||||
{ id: 'settings__custom-views', label: 'Custom views', keywords: ['html', 'upload', 'external', 'embed'] },
|
||||
{ id: 'settings__mcp', label: 'MCP Server', keywords: ['ai', 'agent', 'model context protocol'] },
|
||||
...(isDocker
|
||||
? []
|
||||
: [{ id: 'settings__port', label: 'Server port', keywords: ['http', 'network', 'address', 'listen'] }]),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'project',
|
||||
label: 'Project',
|
||||
secondary: [
|
||||
{ id: 'project__create', label: 'Create...' },
|
||||
{ id: 'project__list', label: 'Manage projects' },
|
||||
{ id: 'project__create', label: 'Create...', keywords: ['new project', 'quick start', 'wizard'] },
|
||||
{
|
||||
id: 'project__list',
|
||||
label: 'Manage projects',
|
||||
keywords: ['load', 'open', 'rename', 'duplicate', 'delete', 'download', 'import', 'backup', 'merge'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'manage',
|
||||
label: 'Project settings',
|
||||
secondary: [
|
||||
{ id: 'manage__defaults', label: 'Rundown defaults' },
|
||||
{ id: 'manage__custom', label: 'Custom fields' },
|
||||
{ id: 'manage__rundowns', label: 'Manage rundowns' },
|
||||
{ id: 'manage__sheets', label: 'Import spreadsheet' },
|
||||
{ id: 'manage__sheets', label: 'Sync with Google Sheet' },
|
||||
{
|
||||
id: 'manage__defaults',
|
||||
label: 'Rundown defaults',
|
||||
keywords: ['duration', 'warning time', 'danger time', 'defaults'],
|
||||
},
|
||||
{
|
||||
id: 'manage__custom',
|
||||
label: 'Custom fields',
|
||||
keywords: ['metadata', 'columns', 'extra data', 'image field'],
|
||||
},
|
||||
{
|
||||
id: 'manage__rundowns',
|
||||
label: 'Manage rundowns',
|
||||
keywords: ['rundown', 'xlsx', 'excel', 'duplicate', 'load'],
|
||||
},
|
||||
{
|
||||
id: 'manage__sheets',
|
||||
label: 'Import spreadsheet',
|
||||
keywords: ['google sheet', 'sync', 'spreadsheet', 'xlsx', 'excel', 'csv', 'export'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'automation',
|
||||
label: 'Automation',
|
||||
secondary: [
|
||||
{ id: 'automation__settings', label: 'Automation settings' },
|
||||
{ id: 'automation__automations', label: 'Manage automations' },
|
||||
{ id: 'automation__triggers', label: 'Manage triggers' },
|
||||
{
|
||||
id: 'automation__settings',
|
||||
label: 'Automation settings',
|
||||
keywords: ['osc input', 'port', 'enable', 'remote control'],
|
||||
},
|
||||
{
|
||||
id: 'automation__automations',
|
||||
label: 'Manage automations',
|
||||
keywords: ['osc', 'http', 'webhook', 'integration', 'api', 'output', 'action'],
|
||||
},
|
||||
{
|
||||
id: 'automation__triggers',
|
||||
label: 'Manage triggers',
|
||||
keywords: ['lifecycle', 'on load', 'on start', 'on finish', 'on update'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'sharing',
|
||||
label: 'Sharing and reporting',
|
||||
secondary: [
|
||||
{ id: 'sharing__presets', label: 'URL Presets' },
|
||||
{ id: 'sharing__presets', label: 'URL Presets', keywords: ['alias', 'link', 'url', 'shortcut'] },
|
||||
{
|
||||
id: 'sharing__link',
|
||||
label: 'Share link',
|
||||
keywords: ['qr code', 'guest', 'cuesheet link', 'permissions', 'read only'],
|
||||
},
|
||||
{ id: 'sharing__report', label: 'Runtime report' },
|
||||
{ id: 'sharing__report', label: 'Runtime report', keywords: ['actual times', 'csv', 'export', 'history'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -70,20 +114,24 @@ const staticOptions = [
|
||||
{
|
||||
id: 'network__log',
|
||||
label: 'Event log',
|
||||
keywords: ['debug', 'errors', 'console', 'export log'],
|
||||
},
|
||||
{
|
||||
id: 'network__clients',
|
||||
label: 'Manage clients',
|
||||
keywords: ['redirect', 'identify', 'rename client', 'connected'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'about',
|
||||
label: 'About',
|
||||
keywords: ['version', 'update', 'licence', 'license', 'credits'],
|
||||
},
|
||||
{
|
||||
id: 'shutdown',
|
||||
label: 'Shutdown',
|
||||
keywords: ['quit', 'exit', 'close ontime'],
|
||||
},
|
||||
] as const;
|
||||
|
||||
@@ -92,6 +140,37 @@ export type SettingsOptionId =
|
||||
| (typeof staticOptions)[number]['id']
|
||||
| Extract<(typeof staticOptions)[number], { secondary: object }>['secondary'][number]['id'];
|
||||
|
||||
function matchesQuery(option: SettingsOption, query: string): boolean {
|
||||
if (option.label.toLowerCase().includes(query)) {
|
||||
return true;
|
||||
}
|
||||
return Boolean(option.keywords?.some((keyword) => keyword.includes(query)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters the settings menu against a user query.
|
||||
* A group is kept if it matches itself (with all its children) or if any of its children match.
|
||||
*/
|
||||
export function filterSettingsOptions(options: Readonly<SettingsOption[]>, query: string): SettingsOption[] {
|
||||
const normalised = query.trim().toLowerCase();
|
||||
if (!normalised) {
|
||||
return [...options];
|
||||
}
|
||||
|
||||
return options.reduce<SettingsOption[]>((accumulator, option) => {
|
||||
if (matchesQuery(option, normalised)) {
|
||||
accumulator.push(option);
|
||||
return accumulator;
|
||||
}
|
||||
|
||||
const secondary = option.secondary?.filter((child) => matchesQuery(child, normalised));
|
||||
if (secondary?.length) {
|
||||
accumulator.push({ ...option, secondary });
|
||||
}
|
||||
return accumulator;
|
||||
}, []);
|
||||
}
|
||||
|
||||
export function useAppSettingsMenu() {
|
||||
const { data } = useAppVersion();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user