From b1848d29821ff53c7e91db13403c09d37dca2758 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Jul 2026 14:30:18 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01UP4knuna8kLe79NAuNDhUJ --- .../panel-content/PanelContent.module.scss | 19 +-- .../panel-content/PanelContent.tsx | 2 +- .../panel-list/PanelList.module.scss | 39 +++++- .../app-settings/panel-list/PanelList.tsx | 103 +++++++++++++--- .../panel-utils/PanelUtils.module.scss | 71 ++++++----- .../app-settings/panel-utils/PanelUtils.tsx | 31 +++-- .../panel-utils/useEntityModal.ts | 29 +++++ .../AutomationForm.module.scss | 10 +- .../automations-panel/AutomationForm.tsx | 45 ++++--- .../automations-panel/AutomationsList.tsx | 28 +++-- .../panel/automations-panel/TriggerForm.tsx | 79 ++++++------ .../panel/automations-panel/TriggersList.tsx | 44 ++++--- .../automations-panel/TriggersListItem.tsx | 43 ++----- .../panel/feature-panel/ReportSettings.tsx | 5 +- .../panel/feature-panel/URLPresets.tsx | 32 ++--- .../feature-panel/composite/URLPresetForm.tsx | 57 +++++---- .../panel/manage-panel/CustomFields.tsx | 60 +++++---- .../panel/manage-panel/CustomViewForm.tsx | 34 ++++-- .../panel/manage-panel/CustomViews.tsx | 5 +- .../panel/manage-panel/CustomViewsList.tsx | 13 +- .../manage-panel/ManagePanel.module.scss | 6 + .../panel/manage-panel/ManageRundownForm.tsx | 40 ++++-- .../panel/manage-panel/ManageRundowns.tsx | 17 +++ .../composite/CustomFieldEntry.tsx | 29 +---- .../composite/CustomFieldForm.tsx | 45 ++++--- .../preview/PreviewTable.module.scss | 27 ---- .../sheet-import/preview/PreviewTable.tsx | 25 ++-- .../client-control/ClientList.tsx | 12 ++ .../panel/project-panel/ManageProjects.tsx | 26 +--- .../panel/project-panel/ProjectCreateForm.tsx | 84 ------------- .../panel/project-panel/ProjectList.tsx | 8 +- .../panel/project-panel/ProjectListItem.tsx | 8 +- .../panel/project-panel/ProjectMergeForm.tsx | 45 ++++--- .../project-panel/ProjectPanel.module.scss | 2 +- .../app-settings/useAppSettingsMenu.tsx | 115 +++++++++++++++--- 35 files changed, 732 insertions(+), 506 deletions(-) create mode 100644 apps/client/src/features/app-settings/panel-utils/useEntityModal.ts delete mode 100644 apps/client/src/features/app-settings/panel/project-panel/ProjectCreateForm.tsx diff --git a/apps/client/src/features/app-settings/panel-content/PanelContent.module.scss b/apps/client/src/features/app-settings/panel-content/PanelContent.module.scss index 265409b70..5acc73d24 100644 --- a/apps/client/src/features/app-settings/panel-content/PanelContent.module.scss +++ b/apps/client/src/features/app-settings/panel-content/PanelContent.module.scss @@ -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; } diff --git a/apps/client/src/features/app-settings/panel-content/PanelContent.tsx b/apps/client/src/features/app-settings/panel-content/PanelContent.tsx index 42d028d9c..fe7930124 100644 --- a/apps/client/src/features/app-settings/panel-content/PanelContent.tsx +++ b/apps/client/src/features/app-settings/panel-content/PanelContent.tsx @@ -12,12 +12,12 @@ interface PanelContentProps { export default function PanelContent({ onClose, children }: PropsWithChildren) { return (
-
{children}
+
{children}
); } diff --git a/apps/client/src/features/app-settings/panel-list/PanelList.module.scss b/apps/client/src/features/app-settings/panel-list/PanelList.module.scss index 6326a1576..d96b29469 100644 --- a/apps/client/src/features/app-settings/panel-list/PanelList.module.scss +++ b/apps/client/src/features/app-settings/panel-list/PanelList.module.scss @@ -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, diff --git a/apps/client/src/features/app-settings/panel-list/PanelList.tsx b/apps/client/src/features/app-settings/panel-list/PanelList.tsx index 01dc977c0..ef384c442 100644 --- a/apps/client/src/features/app-settings/panel-list/PanelList.tsx +++ b/apps/client/src/features/app-settings/panel-list/PanelList.tsx @@ -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(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) => { + 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 ( -
    - {options.map((panel) => { - const isSelected = selectedPanel === panel.id; - if (panel.highlight) { - return ( - }> - - - ); - } - return ; - })} -
+
+
+ + setQuery(event.target.value)} + onKeyDown={handleSearchKeyDown} + placeholder='Search settings' + aria-label='Search settings' + className={style.searchInput} + fluid + /> + {isSearching && ( + { + setQuery(''); + searchRef.current?.focus(); + }} + > + + + )} +
+ + {results.length === 0 ? ( + + ) : ( +
    + {results.map((panel) => { + const isSelected = selectedPanel === panel.id; + if (panel.highlight) { + return ( + }> + + + ); + } + return ; + })} +
+ )} +
); } diff --git a/apps/client/src/features/app-settings/panel-utils/PanelUtils.module.scss b/apps/client/src/features/app-settings/panel-utils/PanelUtils.module.scss index a1e036598..6817ca3e6 100644 --- a/apps/client/src/features/app-settings/panel-utils/PanelUtils.module.scss +++ b/apps/client/src/features/app-settings/panel-utils/PanelUtils.module.scss @@ -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; - } -} diff --git a/apps/client/src/features/app-settings/panel-utils/PanelUtils.tsx b/apps/client/src/features/app-settings/panel-utils/PanelUtils.tsx index ebc996df1..0287fbcb9 100644 --- a/apps/client/src/features/app-settings/panel-utils/PanelUtils.tsx +++ b/apps/client/src/features/app-settings/panel-utils/PanelUtils.tsx @@ -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 ( - +
+
+
{title}
+ {description &&
{description}
} + {action &&
{action}
} +
+
+ ); +} + +export function TableEmpty(props: EmptyStateProps) { + return ( + -
{label ?? 'No data yet'}
- {handleClick && ( - - )} + ); diff --git a/apps/client/src/features/app-settings/panel-utils/useEntityModal.ts b/apps/client/src/features/app-settings/panel-utils/useEntityModal.ts new file mode 100644 index 000000000..18ca12792 --- /dev/null +++ b/apps/client/src/features/app-settings/panel-utils/useEntityModal.ts @@ -0,0 +1,29 @@ +import { useCallback, useState } from 'react'; + +interface EntityModalState { + 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() { + const [state, setState] = useState>({ 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, + }; +} diff --git a/apps/client/src/features/app-settings/panel/automations-panel/AutomationForm.module.scss b/apps/client/src/features/app-settings/panel/automations-panel/AutomationForm.module.scss index d1f772623..fd9ea49cb 100644 --- a/apps/client/src/features/app-settings/panel/automations-panel/AutomationForm.module.scss +++ b/apps/client/src/features/app-settings/panel/automations-panel/AutomationForm.module.scss @@ -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; diff --git a/apps/client/src/features/app-settings/panel/automations-panel/AutomationForm.tsx b/apps/client/src/features/app-settings/panel/automations-panel/AutomationForm.tsx index 545a0e94d..14757412a 100644 --- a/apps/client/src/features/app-settings/panel/automations-panel/AutomationForm.tsx +++ b/apps/client/src/features/app-settings/panel/automations-panel/AutomationForm.tsx @@ -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 ( - preventEscape(event, onClose)} - > - {isEdit ? 'Edit automation' : 'Create automation'} + const bodyElements = ( +

Automation options

@@ -462,14 +456,29 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
+ + ); - - {errors?.root && {errors.root.message}} - - - - + const footerElements = ( + <> + {errors?.root && {errors.root.message}} + + + + ); + + return ( + ); } diff --git a/apps/client/src/features/app-settings/panel/automations-panel/AutomationsList.tsx b/apps/client/src/features/app-settings/panel/automations-panel/AutomationsList.tsx index 3da03d80a..a09c6402b 100644 --- a/apps/client/src/features/app-settings/panel/automations-panel/AutomationsList.tsx +++ b/apps/client/src/features/app-settings/panel/automations-panel/AutomationsList.tsx @@ -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(null); + const automationModal = useEntityModal(); const [deleteError, setDeleteError] = useState(null); const handleDelete = async (id: string) => { @@ -45,13 +46,12 @@ export default function AutomationsList(props: AutomationsListProps) { return ( + {automationModal.isOpen && ( + + )} Manage automations - @@ -65,10 +65,6 @@ export default function AutomationsList(props: AutomationsListProps) { )} - {automationFormData !== null && ( - setAutomationFormData(null)} /> - )} - @@ -82,7 +78,13 @@ export default function AutomationsList(props: AutomationsListProps) { {arrayAutomations.length === 0 && ( 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={ + + } /> )} {arrayAutomations.map((automationId) => { @@ -102,7 +104,7 @@ export default function AutomationsList(props: AutomationsListProps) { setAutomationFormData(automations[automationId])} + onClick={() => automationModal.openEdit(automations[automationId])} > diff --git a/apps/client/src/features/app-settings/panel/automations-panel/TriggerForm.tsx b/apps/client/src/features/app-settings/panel/automations-panel/TriggerForm.tsx index 49cee5a64..e99f84d32 100644 --- a/apps/client/src/features/app-settings/panel/automations-panel/TriggerForm.tsx +++ b/apps/client/src/features/app-settings/panel/automations-panel/TriggerForm.tsx @@ -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({ 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 ( - preventEscape(event, onCancel)} - > - {initialId ? 'Edit trigger' : 'Create trigger'} + const bodyElements = ( +
@@ -127,14 +112,30 @@ export default function TriggerForm({ /> {errors.automationId?.message} - - - - - +
+ ); + + const footerElements = ( + <> + {errors.root && {errors.root.message}} + + + + ); + + return ( + ); } diff --git a/apps/client/src/features/app-settings/panel/automations-panel/TriggersList.tsx b/apps/client/src/features/app-settings/panel/automations-panel/TriggersList.tsx index 2978cdbd9..6e4fa4237 100644 --- a/apps/client/src/features/app-settings/panel/automations-panel/TriggersList.tsx +++ b/apps/client/src/features/app-settings/panel/automations-panel/TriggersList.tsx @@ -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(); const { refetch } = useAutomationSettings(); const [deleteError, setDeleteError] = useState(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 ( + {triggerModal.isOpen && ( + + )} Manage triggers - @@ -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. )} - {showForm && ( - setShowForm(false)} postSubmit={postSubmit} /> - )} @@ -77,10 +83,21 @@ export default function TriggersList(props: TriggersListProps) { - {!showForm && triggers.length === 0 && ( + {triggers.length === 0 && ( 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 && ( + + ) + } /> )} {triggers.map((trigger, index) => { @@ -88,13 +105,10 @@ export default function TriggersList(props: TriggersListProps) { triggerModal.openEdit(trigger)} handleDelete={() => handleDelete(trigger.id)} - postSubmit={postSubmit} /> {deleteError && ( diff --git a/apps/client/src/features/app-settings/panel/automations-panel/TriggersListItem.tsx b/apps/client/src/features/app-settings/panel/automations-panel/TriggersListItem.tsx index 64f01f44d..aecda7a0c 100644 --- a/apps/client/src/features/app-settings/panel/automations-panel/TriggersListItem.tsx +++ b/apps/client/src/features/app-settings/panel/automations-panel/TriggersListItem.tsx @@ -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 ( - - - setIsEditing(false)} - postSubmit={() => { - setIsEditing(false); - postSubmit(); - }} - /> - - - ); - } + const { automations, trigger, duplicate, handleEdit, handleDelete } = props; return ( @@ -52,16 +25,16 @@ export default function TriggersListItem(props: TriggersListItemProps) { color='#FFBC56' // $orange-500 /> )} - {title} + {trigger.title} - {cycles.find((cycle) => cycle.value === trigger)?.label} + {cycles.find((cycle) => cycle.value === trigger.trigger)?.label} - {automations?.[automationId]?.title} + {automations?.[trigger.automationId]?.title} - setIsEditing(true)}> + diff --git a/apps/client/src/features/app-settings/panel/feature-panel/ReportSettings.tsx b/apps/client/src/features/app-settings/panel/feature-panel/ReportSettings.tsx index 7dafb2a65..924bda3d4 100644 --- a/apps/client/src/features/app-settings/panel/feature-panel/ReportSettings.tsx +++ b/apps/client/src/features/app-settings/panel/feature-panel/ReportSettings.tsx @@ -66,7 +66,10 @@ export default function ReportSettings() { {combinedReport.length === 0 && ( - + )} {combinedReport.map((entry) => { diff --git a/apps/client/src/features/app-settings/panel/feature-panel/URLPresets.tsx b/apps/client/src/features/app-settings/panel/feature-panel/URLPresets.tsx index 8cbb5a5a2..e2a3bc8e2 100644 --- a/apps/client/src/features/app-settings/panel/feature-panel/URLPresets.tsx +++ b/apps/client/src/features/app-settings/panel/feature-panel/URLPresets.tsx @@ -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({ isOpen: false, preset: undefined }); + const presetModal = useEntityModal(); const [actionError, setActionError] = useState(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 ( + {presetModal.isOpen && ( + + )} URL presets - @@ -63,7 +58,6 @@ export default function URLPresets() { - {formState.isOpen && } {actionError && {actionError}} @@ -76,7 +70,17 @@ export default function URLPresets() { - {data.length === 0 && } + {data.length === 0 && ( + + Create preset + + } + /> + )} {data.map((preset, index) => { const isCuesheet = preset.target === OntimeView.Cuesheet; return ( @@ -109,7 +113,7 @@ export default function URLPresets() { openEditForm(preset)} + onClick={() => presetModal.openEdit(preset)} variant='ghosted-white' aria-label='Edit entry' data-testid={`field__edit_${index}`} diff --git a/apps/client/src/features/app-settings/panel/feature-panel/composite/URLPresetForm.tsx b/apps/client/src/features/app-settings/panel/feature-panel/composite/URLPresetForm.tsx index 2d87612fc..6d09f7bc1 100644 --- a/apps/client/src/features/app-settings/panel/feature-panel/composite/URLPresetForm.tsx +++ b/apps/client/src/features/app-settings/panel/feature-panel/composite/URLPresetForm.tsx @@ -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[] = [ { 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 ( - preventEscape(event, onClose)} - className={style.column} - > + const bodyElements = ( +
1. Enter URL and let Ontime generate the preset options
@@ -209,20 +206,34 @@ export default function URLPresetForm({ urlPreset, onClose }: URLPresetFormProps />
)} -
- {errors.root?.message} - - - - -
-
+ + ); + + const footerElements = ( + <> + {errors.root?.message} + + + + ); + + return ( + ); } diff --git a/apps/client/src/features/app-settings/panel/manage-panel/CustomFields.tsx b/apps/client/src/features/app-settings/panel/manage-panel/CustomFields.tsx index 971d536d1..e5905dace 100644 --- a/apps/client/src/features/app-settings/panel/manage-panel/CustomFields.tsx +++ b/apps/client/src/features/app-settings/panel/manage-panel/CustomFields.tsx @@ -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(); - 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 ( + {fieldModal.isOpen && ( + + )} Custom fields - @@ -66,7 +72,6 @@ export default function CustomFieldSettings() { - {isAdding && } @@ -78,7 +83,18 @@ export default function CustomFieldSettings() { - {Object.entries(data).map(([key, { colour, label, type }]) => { + {entries.length === 0 && ( + + Create custom field + + } + /> + )} + {entries.map(([key, { colour, label, type }]) => { return ( fieldModal.openEdit({ key, colour, label, type })} onDelete={handleDelete} /> ); diff --git a/apps/client/src/features/app-settings/panel/manage-panel/CustomViewForm.tsx b/apps/client/src/features/app-settings/panel/manage-panel/CustomViewForm.tsx index 015fc4dab..be581d3c1 100644 --- a/apps/client/src/features/app-settings/panel/manage-panel/CustomViewForm.tsx +++ b/apps/client/src/features/app-settings/panel/manage-panel/CustomViewForm.tsx @@ -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 ( - + const bodyElements = ( +
Accepted: index.html only, maximum {maxUploadLabel}. {fileDirty && fileError && {fileError}} +
+ ); + const footerElements = ( + <> {error && {error}} + + + + ); - - - - -
+ return ( + ); } diff --git a/apps/client/src/features/app-settings/panel/manage-panel/CustomViews.tsx b/apps/client/src/features/app-settings/panel/manage-panel/CustomViews.tsx index f8dd09949..695a564d7 100644 --- a/apps/client/src/features/app-settings/panel/manage-panel/CustomViews.tsx +++ b/apps/client/src/features/app-settings/panel/manage-panel/CustomViews.tsx @@ -37,13 +37,14 @@ export default function CustomViews() { return ( + {isUploadOpen && setIsUploadOpen(false)} />} Custom views - @@ -63,8 +64,6 @@ export default function CustomViews() { - {isUploadOpen && setIsUploadOpen(false)} />} - {actionError && {actionError}} - {views.length === 0 && } + {views.length === 0 && ( + + Upload view + + } + /> + )} {views.map((view, index) => ( + const bodyElements = ( +
- - - - +
+ ); + + const footerElements = ( + <> {errors.root && {errors.root.message}} - + + + + ); + + return ( + ); } diff --git a/apps/client/src/features/app-settings/panel/manage-panel/ManageRundowns.tsx b/apps/client/src/features/app-settings/panel/manage-panel/ManageRundowns.tsx index 0bc3e6c9b..561f8f667 100644 --- a/apps/client/src/features/app-settings/panel/manage-panel/ManageRundowns.tsx +++ b/apps/client/src/features/app-settings/panel/manage-panel/ManageRundowns.tsx @@ -131,6 +131,23 @@ export default function ManageRundowns() { + {data?.rundowns?.length === 0 && ( + { + setActionError(null); + newHandlers.open(); + }} + > + Create rundown + + } + /> + )} {data?.rundowns?.map(({ id, numEntries, title }) => { const isLoaded = data.loaded === id; const isRenaming = renamingRundown === id; diff --git a/apps/client/src/features/app-settings/panel/manage-panel/composite/CustomFieldEntry.tsx b/apps/client/src/features/app-settings/panel/manage-panel/composite/CustomFieldEntry.tsx index 17a12797c..0b73222f5 100644 --- a/apps/client/src/features/app-settings/panel/manage-panel/composite/CustomFieldEntry.tsx +++ b/apps/client/src/features/app-settings/panel/manage-panel/composite/CustomFieldEntry.tsx @@ -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; + onEdit: () => void; onDelete: (key: CustomFieldKey) => Promise; } 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 ( - - - setIsEditing(false)} - onSubmit={handleEdit} - initialColour={colour} - initialLabel={label} - initialKey={fieldKey} - initialType={type} - /> - - - ); - } return ( @@ -61,7 +36,7 @@ export default function CustomFieldEntry(props: CustomFieldEntryProps) { - setIsEditing(true)}> + onDelete(fieldKey)}> diff --git a/apps/client/src/features/app-settings/panel/manage-panel/composite/CustomFieldForm.tsx b/apps/client/src/features/app-settings/panel/manage-panel/composite/CustomFieldForm.tsx index 01387ec2e..d5932e628 100644 --- a/apps/client/src/features/app-settings/panel/manage-panel/composite/CustomFieldForm.tsx +++ b/apps/client/src/features/app-settings/panel/manage-panel/composite/CustomFieldForm.tsx @@ -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; 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 ( - preventEscape(event, onCancel)}> + const bodyElements = ( +
Please note that images can quickly deteriorate your app's performance.
@@ -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({ Colour handleSelectColour(value)} /> + + ); + + const footerElements = ( + <> {errors.root && {errors.root.message}} - - - - -
+ + + + ); + + return ( + ); } diff --git a/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/sheet-import/preview/PreviewTable.module.scss b/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/sheet-import/preview/PreviewTable.module.scss index 4c223d366..002662fd2 100644 --- a/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/sheet-import/preview/PreviewTable.module.scss +++ b/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/sheet-import/preview/PreviewTable.module.scss @@ -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; diff --git a/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/sheet-import/preview/PreviewTable.tsx b/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/sheet-import/preview/PreviewTable.tsx index b69cdca5d..44a1f1c77 100644 --- a/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/sheet-import/preview/PreviewTable.tsx +++ b/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/sheet-import/preview/PreviewTable.tsx @@ -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 ( -
-
-
{emptyTitle}
-
{emptyContent}
- {needsPreviewRefresh && ( - - )} -
-
+ ) + } + /> ); } diff --git a/apps/client/src/features/app-settings/panel/network-panel/client-control/ClientList.tsx b/apps/client/src/features/app-settings/panel/network-panel/client-control/ClientList.tsx index 70e7a5778..d769fb526 100644 --- a/apps/client/src/features/app-settings/panel/network-panel/client-control/ClientList.tsx +++ b/apps/client/src/features/app-settings/panel/network-panel/client-control/ClientList.tsx @@ -68,6 +68,12 @@ export default function ClientList() { + {ontimeClients.length === 0 && ( + + )} {ontimeClients.map(([key, client]) => { const { identify, name, path } = client; const isCurrent = id === key; @@ -125,6 +131,12 @@ export default function ClientList() { + {otherClients.length === 0 && ( + + )} {otherClients.map(([key, client]) => { const { name, type } = client; diff --git a/apps/client/src/features/app-settings/panel/project-panel/ManageProjects.tsx b/apps/client/src/features/app-settings/panel/project-panel/ManageProjects.tsx index f47ef304b..7f2d8273a 100644 --- a/apps/client/src/features/app-settings/panel/project-panel/ManageProjects.tsx +++ b/apps/client/src/features/app-settings/panel/project-panel/ManageProjects.tsx @@ -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(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 ( Manage projects - -
{error && {error}} - {isCreatingProject && }
diff --git a/apps/client/src/features/app-settings/panel/project-panel/ProjectCreateForm.tsx b/apps/client/src/features/app-settings/panel/project-panel/ProjectCreateForm.tsx deleted file mode 100644 index 90519f303..000000000 --- a/apps/client/src/features/app-settings/panel/project-panel/ProjectCreateForm.tsx +++ /dev/null @@ -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(null); - - const { - handleSubmit, - register, - formState: { isSubmitting, isValid }, - setFocus, - } = useForm({ - 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 ( - preventEscape(event, onClose)} - > - - Create new project - - - - - - {error && {error}} - - Project title - - - - ); -} diff --git a/apps/client/src/features/app-settings/panel/project-panel/ProjectList.tsx b/apps/client/src/features/app-settings/panel/project-panel/ProjectList.tsx index ad4feb8d6..f2b2bd3d0 100644 --- a/apps/client/src/features/app-settings/panel/project-panel/ProjectList.tsx +++ b/apps/client/src/features/app-settings/panel/project-panel/ProjectList.tsx @@ -40,7 +40,7 @@ export default function ProjectList() { if (status === 'pending') { return ( -
+
); @@ -74,6 +74,12 @@ export default function ProjectList() { + {numProjects === 0 && ( + + )} {data.reorderedProjectFiles.map((project) => ( )} - {showMergeForm && ( - - - - - - )} + {showMergeForm && } setDeleteOpen(false)} diff --git a/apps/client/src/features/app-settings/panel/project-panel/ProjectMergeForm.tsx b/apps/client/src/features/app-settings/panel/project-panel/ProjectMergeForm.tsx index f293123fa..15fc015b4 100644 --- a/apps/client/src/features/app-settings/panel/project-panel/ProjectMergeForm.tsx +++ b/apps/client/src/features/app-settings/panel/project-panel/ProjectMergeForm.tsx @@ -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 ( - - - Partial project merge - - - - - - {error && {error}} + const bodyElements = ( +
Select data from {`"${fileName}"`} to merge into the current project. @@ -139,6 +130,30 @@ export default function ProjectMergeForm({ onClose, fileName }: ProjectMergeFrom Automation Settings - +
+ ); + + const footerElements = ( + <> + {error && {error}} + + + + ); + + return ( + ); } diff --git a/apps/client/src/features/app-settings/panel/project-panel/ProjectPanel.module.scss b/apps/client/src/features/app-settings/panel/project-panel/ProjectPanel.module.scss index f41a56f17..eaee0c59d 100644 --- a/apps/client/src/features/app-settings/panel/project-panel/ProjectPanel.module.scss +++ b/apps/client/src/features/app-settings/panel/project-panel/ProjectPanel.module.scss @@ -55,7 +55,7 @@ margin-bottom: 1rem; } -.empty { +.loaderBox { height: 300px; position: relative; } diff --git a/apps/client/src/features/app-settings/useAppSettingsMenu.tsx b/apps/client/src/features/app-settings/useAppSettingsMenu.tsx index 370f0cf92..f9e7be2b4 100644 --- a/apps/client/src/features/app-settings/useAppSettingsMenu.tsx +++ b/apps/client/src/features/app-settings/useAppSettingsMenu.tsx @@ -8,6 +8,8 @@ export type SettingsOption = { label: string; secondary?: Readonly; highlight?: string; + /** alternative terms a user may search for when looking for this section */ + keywords?: Readonly; }; 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, query: string): SettingsOption[] { + const normalised = query.trim().toLowerCase(); + if (!normalised) { + return [...options]; + } + + return options.reduce((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();