From 9a03e4850a0d8291ed9d54ce0cc4e0f2e37c3fe7 Mon Sep 17 00:00:00 2001 From: Carlos Valente <34649812+cpvalente@users.noreply.github.com> Date: Sun, 31 Mar 2024 21:18:24 +0200 Subject: [PATCH 1/9] Navigation3 (#860) --- apps/client/src/AppRouter.tsx | 2 +- .../navigation-menu/FloatingNavigation.tsx | 60 ++++++++++ .../navigation-menu/NavigationMenu.tsx | 86 +++----------- .../ProductionNavigationMenu.tsx | 8 +- .../navigation-menu/ViewNavigationMenu.tsx | 111 ++++++++++-------- .../client/src/common/hooks/useWindowTitle.ts | 11 ++ .../cuesheet/CuesheetWrapper.module.scss | 2 +- .../src/features/cuesheet/CuesheetWrapper.tsx | 31 +++-- apps/client/src/features/editors/Editor.tsx | 43 +++++-- .../client/src/features/operator/Operator.tsx | 15 +-- .../src/features/operator/OperatorExport.tsx | 28 +++++ .../features/overview/Overview.module.scss | 14 ++- .../client/src/features/overview/Overview.tsx | 30 +++-- .../features/viewers/backstage/Backstage.tsx | 6 +- .../src/features/viewers/clock/Clock.tsx | 6 +- .../features/viewers/countdown/Countdown.tsx | 5 +- .../viewers/lower-thirds/LowerThird.tsx | 6 +- .../viewers/minimal-timer/MinimalTimer.tsx | 6 +- .../src/features/viewers/public/Public.tsx | 7 +- .../features/viewers/studio/StudioClock.tsx | 6 +- .../src/features/viewers/timer/Timer.tsx | 6 +- e2e/tests/000-upload-showfile.spec.ts | 2 +- e2e/tests/features/206-url-preset.spec.ts | 7 +- .../features/301-spreadsheet-import.spec.ts | 2 +- 24 files changed, 290 insertions(+), 210 deletions(-) create mode 100644 apps/client/src/common/components/navigation-menu/FloatingNavigation.tsx create mode 100644 apps/client/src/common/hooks/useWindowTitle.ts create mode 100644 apps/client/src/features/operator/OperatorExport.tsx diff --git a/apps/client/src/AppRouter.tsx b/apps/client/src/AppRouter.tsx index e43683833..2f34d2acf 100644 --- a/apps/client/src/AppRouter.tsx +++ b/apps/client/src/AppRouter.tsx @@ -7,7 +7,7 @@ import withData from './features/viewers/ViewWrapper'; const Editor = lazy(() => import('./features/editors/ProtectedEditor')); const Cuesheet = lazy(() => import('./features/cuesheet/ProtectedCuesheet')); -const Operator = lazy(() => import('./features/operator/Operator')); +const Operator = lazy(() => import('./features/operator/OperatorExport')); const TimerView = lazy(() => import('./features/viewers/timer/Timer')); const MinimalTimerView = lazy(() => import('./features/viewers/minimal-timer/MinimalTimer')); diff --git a/apps/client/src/common/components/navigation-menu/FloatingNavigation.tsx b/apps/client/src/common/components/navigation-menu/FloatingNavigation.tsx new file mode 100644 index 000000000..a4b692a9c --- /dev/null +++ b/apps/client/src/common/components/navigation-menu/FloatingNavigation.tsx @@ -0,0 +1,60 @@ +import { useEffect, useState } from 'react'; +import { IoApps } from '@react-icons/all-files/io5/IoApps'; +import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline'; + +import { debounce } from '../../utils/debounce'; + +import style from './NavigationMenu.module.scss'; + +interface FloatingNavigationProps { + toggleMenu: () => void; + toggleSettings: () => void; +} + +export default function FloatingNavigation(props: FloatingNavigationProps) { + const { toggleMenu, toggleSettings } = props; + const [showButton, setShowButton] = useState(false); + + // show on mouse move + useEffect(() => { + let fadeOut: NodeJS.Timeout | null = null; + const setShowMenuTrue = () => { + setShowButton(true); + if (fadeOut) { + clearTimeout(fadeOut); + } + fadeOut = setTimeout(() => setShowButton(false), 3000); + }; + + const debouncedShowMenu = debounce(setShowMenuTrue, 1000); + + document.addEventListener('mousemove', debouncedShowMenu); + return () => { + document.removeEventListener('mousemove', debouncedShowMenu); + if (fadeOut) { + clearTimeout(fadeOut); + } + }; + }, []); + + return ( +
+ + +
+ ); +} diff --git a/apps/client/src/common/components/navigation-menu/NavigationMenu.tsx b/apps/client/src/common/components/navigation-menu/NavigationMenu.tsx index d4660ec58..7a5e7aba9 100644 --- a/apps/client/src/common/components/navigation-menu/NavigationMenu.tsx +++ b/apps/client/src/common/components/navigation-menu/NavigationMenu.tsx @@ -1,89 +1,33 @@ -import { memo, PropsWithChildren, useEffect, useRef, useState } from 'react'; +import { memo, PropsWithChildren, useRef } from 'react'; import { createPortal } from 'react-dom'; -import { - Drawer, - DrawerBody, - DrawerCloseButton, - DrawerContent, - DrawerHeader, - DrawerOverlay, - useDisclosure, -} from '@chakra-ui/react'; -import { IoApps } from '@react-icons/all-files/io5/IoApps'; -import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline'; +import { Drawer, DrawerBody, DrawerCloseButton, DrawerContent, DrawerHeader, DrawerOverlay } from '@chakra-ui/react'; import useClickOutside from '../../hooks/useClickOutside'; -import { debounce } from '../../utils/debounce'; - -import style from './NavigationMenu.module.scss'; interface NavigationMenuProps { - editCallback: () => void; + isOpen: boolean; + onClose: () => void; } function NavigationMenu(props: PropsWithChildren) { - const { children, editCallback } = props; + const { children, isOpen, onClose } = props; - const [showButton, setShowButton] = useState(false); - const { isOpen, onOpen, onClose } = useDisclosure(); const menuRef = useRef(null); useClickOutside(menuRef, () => onClose); - const toggleMenu = () => (isOpen ? onClose() : onOpen()); - - // show on mouse move - useEffect(() => { - let fadeOut: NodeJS.Timeout | null = null; - const setShowMenuTrue = () => { - setShowButton(true); - if (fadeOut) { - clearTimeout(fadeOut); - } - fadeOut = setTimeout(() => setShowButton(false), 3000); - }; - - const debouncedShowMenu = debounce(setShowMenuTrue, 1000); - - document.addEventListener('mousemove', debouncedShowMenu); - return () => { - document.removeEventListener('mousemove', debouncedShowMenu); - if (fadeOut) { - clearTimeout(fadeOut); - } - }; - }, []); - return createPortal( , document.body, ); diff --git a/apps/client/src/common/components/navigation-menu/ProductionNavigationMenu.tsx b/apps/client/src/common/components/navigation-menu/ProductionNavigationMenu.tsx index 12ee12216..60ad24587 100644 --- a/apps/client/src/common/components/navigation-menu/ProductionNavigationMenu.tsx +++ b/apps/client/src/common/components/navigation-menu/ProductionNavigationMenu.tsx @@ -15,16 +15,18 @@ import NavigationMenu from './NavigationMenu'; import style from './NavigationMenu.module.scss'; interface ProductionNavigationMenuProps { - handleSettings: () => void; + isMenuOpen: boolean; + onMenuClose: () => void; } -function ProductionNavigationMenu({ handleSettings }: ProductionNavigationMenuProps) { +function ProductionNavigationMenu(props: ProductionNavigationMenuProps) { + const { isMenuOpen, onMenuClose } = props; const location = useLocation(); const { fullscreen, toggle } = useFullscreen(); const { isOpen, onOpen, onClose } = useDisclosure(); return ( - +
{ searchParams.set('edit', 'true'); setSearchParams(searchParams); }, [searchParams, setSearchParams]); + const toggleMenu = () => (isMenuOpen ? onMenuClose() : onMenuOpen()); + return ( - - -
-
{ - isKeyEnter(event) && toggle(); - }} - > - Toggle Fullscreen - {fullscreen ? : } + <> + + + +
+
{ + isKeyEnter(event) && toggle(); + }} + > + Toggle Fullscreen + {fullscreen ? : } +
+
toggleMirror()} + onKeyDown={(event) => { + isKeyEnter(event) && toggleMirror(); + }} + > + Flip Screen + +
+
{ + isKeyEnter(event) && onRenameOpen(); + }} + > + Rename Client +
-
toggleMirror()} - onKeyDown={(event) => { - isKeyEnter(event) && toggleMirror(); - }} - > - Flip Screen - -
-
{ - isKeyEnter(event) && onOpen(); - }} - > - Rename Client -
-
-
- {navigatorConstants.map((route) => ( - - {route.label} - - - ))} - +
+ {navigatorConstants.map((route) => ( + + {route.label} + + + ))} + + ); } diff --git a/apps/client/src/common/hooks/useWindowTitle.ts b/apps/client/src/common/hooks/useWindowTitle.ts new file mode 100644 index 000000000..78603d38f --- /dev/null +++ b/apps/client/src/common/hooks/useWindowTitle.ts @@ -0,0 +1,11 @@ +import { useEffect } from 'react'; + +/** + * Sets tab title + * @param title + */ +export function useWindowTitle(title: string) { + useEffect(() => { + document.title = `ontime - ${title}`; + }, []); +} diff --git a/apps/client/src/features/cuesheet/CuesheetWrapper.module.scss b/apps/client/src/features/cuesheet/CuesheetWrapper.module.scss index eb82af77e..61f9a47fe 100644 --- a/apps/client/src/features/cuesheet/CuesheetWrapper.module.scss +++ b/apps/client/src/features/cuesheet/CuesheetWrapper.module.scss @@ -1,7 +1,7 @@ .tableWrapper { width: 100%; height: 100vh; - padding: 1rem; + padding: 1rem 0.5rem; display: grid; grid-template-rows: 3rem auto 1fr; diff --git a/apps/client/src/features/cuesheet/CuesheetWrapper.tsx b/apps/client/src/features/cuesheet/CuesheetWrapper.tsx index 9ab91dd6d..8ab2593e5 100644 --- a/apps/client/src/features/cuesheet/CuesheetWrapper.tsx +++ b/apps/client/src/features/cuesheet/CuesheetWrapper.tsx @@ -1,10 +1,14 @@ -import { useCallback, useEffect, useMemo } from 'react'; +import { useCallback, useMemo } from 'react'; +import { IconButton, useDisclosure } from '@chakra-ui/react'; +import { IoApps } from '@react-icons/all-files/io5/IoApps'; +import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline'; import { CustomFieldLabel, isOntimeEvent } from 'ontime-types'; import ProductionNavigationMenu from '../../common/components/navigation-menu/ProductionNavigationMenu'; import Empty from '../../common/components/state/Empty'; import { useEventAction } from '../../common/hooks/useEventAction'; import { useCuesheet } from '../../common/hooks/useSocket'; +import { useWindowTitle } from '../../common/hooks/useWindowTitle'; import useCustomFields from '../../common/hooks-query/useCustomFields'; import { useFlatRundown } from '../../common/hooks-query/useRundown'; import Overview from '../overview/Overview'; @@ -20,16 +24,14 @@ export default function CuesheetWrapper() { // TODO: can we use the normalised rundown for the table? const { data: flatRundown, status: rundownStatus } = useFlatRundown(); const { data: customFields } = useCustomFields(); + const { isOpen: isMenuOpen, onOpen, onClose } = useDisclosure(); const { updateCustomField } = useEventAction(); const featureData = useCuesheet(); const columns = useMemo(() => makeCuesheetColumns(customFields), [customFields]); const toggleSettings = useCuesheetSettings((state) => state.toggleSettings); - // Set window title - useEffect(() => { - document.title = 'ontime - Cuesheet'; - }, []); + useWindowTitle('Cuesheet'); /** * Handles updating a field @@ -82,9 +84,24 @@ export default function CuesheetWrapper() { return (
- + + + } + onClick={onOpen} + /> + } + onClick={() => toggleSettings()} + /> + - toggleSettings()} /> import('../control/playback/TimerControlExport') const MessageControl = lazy(() => import('../control/message/MessageControlExport')); export default function Editor() { - const { isOpen, setLocation, close } = useAppSettingsNavigation(); + const { isOpen: isSettingsOpen, setLocation, close } = useAppSettingsNavigation(); const { isElectron } = useElectronEvent(); + const { isOpen: isMenuOpen, onOpen, onClose } = useDisclosure(); - const handleSettings = useCallback(() => { - if (isOpen) { + const toggleSettings = useCallback(() => { + if (isSettingsOpen) { close(); } else { setLocation('project'); } - }, [close, isOpen, setLocation]); + }, [close, isSettingsOpen, setLocation]); // Handle keyboard shortcuts const handleKeyPress = useCallback( @@ -34,13 +39,13 @@ export default function Editor() { if (event.ctrlKey || event.metaKey) { // ctrl + , (settings) if (event.key === ',') { - handleSettings(); + toggleSettings(); event.preventDefault(); event.stopPropagation(); } } }, - [handleSettings], + [toggleSettings], ); // register ctrl + , to open settings @@ -55,15 +60,28 @@ export default function Editor() { }; }, [handleKeyPress, isElectron]); - // Set window title - useEffect(() => { - document.title = 'ontime - Editor'; - }, []); + useWindowTitle('Editor'); return (
- - {isOpen ? ( + + + } + onClick={onOpen} + /> + } + onClick={toggleSettings} + /> + + {isSettingsOpen ? ( ) : (
@@ -74,7 +92,6 @@ export default function Editor() {
)} -
); } diff --git a/apps/client/src/features/operator/Operator.tsx b/apps/client/src/features/operator/Operator.tsx index 7e8aa855d..10eb56f6c 100644 --- a/apps/client/src/features/operator/Operator.tsx +++ b/apps/client/src/features/operator/Operator.tsx @@ -3,12 +3,12 @@ import { useSearchParams } from 'react-router-dom'; import { CustomField, CustomFields, isOntimeEvent, OntimeEvent, SupportedEvent } from 'ontime-types'; import { getFirstEventNormal, getLastEventNormal } from 'ontime-utils'; -import ProductionNavigationMenu from '../../common/components/navigation-menu/ProductionNavigationMenu'; import Empty from '../../common/components/state/Empty'; import { getOperatorOptions } from '../../common/components/view-params-editor/constants'; import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor'; import useFollowComponent from '../../common/hooks/useFollowComponent'; import { useOperator } from '../../common/hooks/useSocket'; +import { useWindowTitle } from '../../common/hooks/useWindowTitle'; import useCustomFields from '../../common/hooks-query/useCustomFields'; import useProjectData from '../../common/hooks-query/useProjectData'; import useRundown from '../../common/hooks-query/useRundown'; @@ -41,7 +41,7 @@ export default function Operator() { const timeoutId = useRef(null); const featureData = useOperator(); - const [searchParams, setSearchParams] = useSearchParams(); + const [searchParams] = useSearchParams(); const { data: settings } = useSettings(); const [showEditPrompt, setShowEditPrompt] = useState(false); @@ -57,10 +57,7 @@ export default function Operator() { topOffset: selectedOffset, }); - // Set window title - useEffect(() => { - document.title = 'ontime - Operator'; - }, []); + useWindowTitle('Operator'); // reset scroll if nothing is selected useEffect(() => { @@ -116,11 +113,6 @@ export default function Operator() { [searchParams], ); - const showEditFormDrawer = useCallback(() => { - searchParams.set('edit', 'true'); - setSearchParams(searchParams); - }, [searchParams, setSearchParams]); - const missingData = !data || !customFields || !projectData; const isLoading = status === 'pending' || customFieldStatus === 'pending' || projectDataStatus === 'pending'; @@ -146,7 +138,6 @@ export default function Operator() { return (
- {editEvent && setEditEvent(null)} />} diff --git a/apps/client/src/features/operator/OperatorExport.tsx b/apps/client/src/features/operator/OperatorExport.tsx new file mode 100644 index 000000000..fe5e8389b --- /dev/null +++ b/apps/client/src/features/operator/OperatorExport.tsx @@ -0,0 +1,28 @@ +import { useCallback } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { useDisclosure } from '@chakra-ui/react'; + +import FloatingNavigation from '../../common/components/navigation-menu/FloatingNavigation'; +import ProductionNavigationMenu from '../../common/components/navigation-menu/ProductionNavigationMenu'; + +import Operator from './Operator'; + +export default function OperatorExport() { + const [searchParams, setSearchParams] = useSearchParams(); + const { isOpen, onOpen, onClose } = useDisclosure(); + + const showEditFormDrawer = useCallback(() => { + searchParams.set('edit', 'true'); + setSearchParams(searchParams); + }, [searchParams, setSearchParams]); + + const toggleMenu = isOpen ? onClose : onOpen; + + return ( + <> + + + + + ); +} diff --git a/apps/client/src/features/overview/Overview.module.scss b/apps/client/src/features/overview/Overview.module.scss index 57cbd04c7..4aa87687e 100644 --- a/apps/client/src/features/overview/Overview.module.scss +++ b/apps/client/src/features/overview/Overview.module.scss @@ -1,10 +1,20 @@ .overview { grid-area: overview; + font-size: $inner-section-text-size; + display: flex; +} + +.nav { + display: flex; + gap: 0.5rem; +} + +.info { + flex: 1; + padding-inline: 1rem; display: flex; align-items: center; justify-content: space-between; - font-size: $inner-section-text-size; - padding: 0 1rem; } .title { diff --git a/apps/client/src/features/overview/Overview.tsx b/apps/client/src/features/overview/Overview.tsx index c2f6ec320..d2b138ec0 100644 --- a/apps/client/src/features/overview/Overview.tsx +++ b/apps/client/src/features/overview/Overview.tsx @@ -12,7 +12,7 @@ import style from './Overview.module.scss'; export default memo(Overview); -function Overview() { +function Overview({ children }: { children: React.ReactNode }) { const { plannedEnd, plannedStart, actualStart, expectedEnd } = useRuntimeOverview(); const [maybePlannedEnd, maybePlannedDaySpan] = useMemo(() => calculateEndAndDaySpan(plannedEnd), [plannedEnd]); @@ -24,15 +24,23 @@ function Overview() { return (
- -
- - -
- -
- - +
{children}
+
+ +
+ + +
+ +
+ + +
@@ -43,7 +51,7 @@ function TitlesOverview() { const { data } = useProjectData(); return ( -
+
{data.title}
{data.description}
diff --git a/apps/client/src/features/viewers/backstage/Backstage.tsx b/apps/client/src/features/viewers/backstage/Backstage.tsx index 3be11f2d8..3f0e7026d 100644 --- a/apps/client/src/features/viewers/backstage/Backstage.tsx +++ b/apps/client/src/features/viewers/backstage/Backstage.tsx @@ -14,6 +14,7 @@ import TitleCard from '../../../common/components/title-card/TitleCard'; import { getBackstageOptions } from '../../../common/components/view-params-editor/constants'; import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor'; import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet'; +import { useWindowTitle } from '../../../common/hooks/useWindowTitle'; import { ViewExtendedTimer } from '../../../common/models/TimeManager.type'; import { formatTime, getDefaultFormat } from '../../../common/utils/time'; import { useTranslation } from '../../../translation/TranslationProvider'; @@ -57,10 +58,7 @@ export default function Backstage(props: BackstageProps) { const [blinkClass, setBlinkClass] = useState(false); const [searchParams] = useSearchParams(); - // Set window title - useEffect(() => { - document.title = 'ontime - Backstage Screen'; - }, []); + useWindowTitle('Backstage'); // blink on change useEffect(() => { diff --git a/apps/client/src/features/viewers/clock/Clock.tsx b/apps/client/src/features/viewers/clock/Clock.tsx index 35168f8e5..ec6c7ccbe 100644 --- a/apps/client/src/features/viewers/clock/Clock.tsx +++ b/apps/client/src/features/viewers/clock/Clock.tsx @@ -1,4 +1,3 @@ -import { useEffect } from 'react'; import { useSearchParams } from 'react-router-dom'; import { Settings, ViewSettings } from 'ontime-types'; @@ -6,6 +5,7 @@ import { overrideStylesURL } from '../../../common/api/constants'; import { getClockOptions } from '../../../common/components/view-params-editor/constants'; import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor'; import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet'; +import { useWindowTitle } from '../../../common/hooks/useWindowTitle'; import { ViewExtendedTimer } from '../../../common/models/TimeManager.type'; import { OverridableOptions } from '../../../common/models/View.types'; import { formatTime, getDefaultFormat } from '../../../common/utils/time'; @@ -25,9 +25,7 @@ export default function Clock(props: ClockProps) { const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL); const [searchParams] = useSearchParams(); - useEffect(() => { - document.title = 'ontime - Clock'; - }, []); + useWindowTitle('Clock'); // defer rendering until we load stylesheets if (!shouldRender) { diff --git a/apps/client/src/features/viewers/countdown/Countdown.tsx b/apps/client/src/features/viewers/countdown/Countdown.tsx index 0b7f42d29..3713b5903 100644 --- a/apps/client/src/features/viewers/countdown/Countdown.tsx +++ b/apps/client/src/features/viewers/countdown/Countdown.tsx @@ -7,6 +7,7 @@ import { overrideStylesURL } from '../../../common/api/constants'; import { getCountdownOptions } from '../../../common/components/view-params-editor/constants'; import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor'; import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet'; +import { useWindowTitle } from '../../../common/hooks/useWindowTitle'; import { ViewExtendedTimer } from '../../../common/models/TimeManager.type'; import { formatTime, getDefaultFormat } from '../../../common/utils/time'; import { useTranslation } from '../../../translation/TranslationProvider'; @@ -37,9 +38,7 @@ export default function Countdown(props: CountdownProps) { const [runningMessage, setRunningMessage] = useState(TimerMessage.unhandled); const [delay, setDelay] = useState(0); - useEffect(() => { - document.title = 'ontime - Countdown'; - }, []); + useWindowTitle('Countdown'); // eg. http://localhost:4001/countdown?eventId=ei0us // Check for user options diff --git a/apps/client/src/features/viewers/lower-thirds/LowerThird.tsx b/apps/client/src/features/viewers/lower-thirds/LowerThird.tsx index 0c8f46f52..5c6b3c348 100644 --- a/apps/client/src/features/viewers/lower-thirds/LowerThird.tsx +++ b/apps/client/src/features/viewers/lower-thirds/LowerThird.tsx @@ -6,6 +6,7 @@ import { overrideStylesURL } from '../../../common/api/constants'; import { getLowerThirdOptions } from '../../../common/components/view-params-editor/constants'; import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor'; import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet'; +import { useWindowTitle } from '../../../common/hooks/useWindowTitle'; import { getPropertyValue } from '../common/viewUtils'; import './LowerThird.scss'; @@ -141,10 +142,7 @@ export default function LowerThird(props: LowerProps) { const [playState, setPlayState] = useState<'pre' | 'in' | 'out'>('pre'); useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL); - // set window title - useEffect(() => { - document.title = 'ontime - Lower Third'; - }, []); + useWindowTitle('Lower Third'); const trigger = useMemo(() => { if (options.trigger === TriggerType.Event) { diff --git a/apps/client/src/features/viewers/minimal-timer/MinimalTimer.tsx b/apps/client/src/features/viewers/minimal-timer/MinimalTimer.tsx index d0986e301..663bbeb05 100644 --- a/apps/client/src/features/viewers/minimal-timer/MinimalTimer.tsx +++ b/apps/client/src/features/viewers/minimal-timer/MinimalTimer.tsx @@ -1,4 +1,3 @@ -import { useEffect } from 'react'; import { useSearchParams } from 'react-router-dom'; import { Playback, TimerMessage, TimerType, ViewSettings } from 'ontime-types'; import { MILLIS_PER_SECOND, millisToString, removeLeadingZero, removeSeconds } from 'ontime-utils'; @@ -7,6 +6,7 @@ import { overrideStylesURL } from '../../../common/api/constants'; import { MINIMAL_TIMER_OPTIONS } from '../../../common/components/view-params-editor/constants'; import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor'; import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet'; +import { useWindowTitle } from '../../../common/hooks/useWindowTitle'; import { ViewExtendedTimer } from '../../../common/models/TimeManager.type'; import { OverridableOptions } from '../../../common/models/View.types'; import { timerPlaceholder } from '../../../common/utils/styleUtils'; @@ -28,9 +28,7 @@ export default function MinimalTimer(props: MinimalTimerProps) { const { getLocalizedString } = useTranslation(); const [searchParams] = useSearchParams(); - useEffect(() => { - document.title = 'ontime - Minimal Timer'; - }, []); + useWindowTitle('Minimal Timer'); // defer rendering until we load stylesheets if (!shouldRender) { diff --git a/apps/client/src/features/viewers/public/Public.tsx b/apps/client/src/features/viewers/public/Public.tsx index 52895fd71..d217faac9 100644 --- a/apps/client/src/features/viewers/public/Public.tsx +++ b/apps/client/src/features/viewers/public/Public.tsx @@ -1,4 +1,3 @@ -import { useEffect } from 'react'; import QRCode from 'react-qr-code'; import { useSearchParams } from 'react-router-dom'; import { AnimatePresence, motion } from 'framer-motion'; @@ -12,6 +11,7 @@ import TitleCard from '../../../common/components/title-card/TitleCard'; import { getPublicOptions } from '../../../common/components/view-params-editor/constants'; import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor'; import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet'; +import { useWindowTitle } from '../../../common/hooks/useWindowTitle'; import { ViewExtendedTimer } from '../../../common/models/TimeManager.type'; import { formatTime, getDefaultFormat } from '../../../common/utils/time'; import { useTranslation } from '../../../translation/TranslationProvider'; @@ -54,10 +54,7 @@ export default function Public(props: BackstageProps) { const { getLocalizedString } = useTranslation(); const [searchParams] = useSearchParams(); - // set window title - useEffect(() => { - document.title = 'ontime - Public Screen'; - }, []); + useWindowTitle('Public Schedule'); // defer rendering until we load stylesheets if (!shouldRender) { diff --git a/apps/client/src/features/viewers/studio/StudioClock.tsx b/apps/client/src/features/viewers/studio/StudioClock.tsx index b74e28016..e48f985df 100644 --- a/apps/client/src/features/viewers/studio/StudioClock.tsx +++ b/apps/client/src/features/viewers/studio/StudioClock.tsx @@ -1,4 +1,3 @@ -import { useEffect } from 'react'; import { useSearchParams } from 'react-router-dom'; import type { OntimeEvent, OntimeRundown, Settings, ViewSettings } from 'ontime-types'; import { isOntimeEvent, Playback } from 'ontime-types'; @@ -9,6 +8,7 @@ import { getStudioClockOptions } from '../../../common/components/view-params-ed import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor'; import useFitText from '../../../common/hooks/useFitText'; import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet'; +import { useWindowTitle } from '../../../common/hooks/useWindowTitle'; import { ViewExtendedTimer } from '../../../common/models/TimeManager.type'; import { formatTime, getDefaultFormat } from '../../../common/utils/time'; import SuperscriptTime from '../common/superscript-time/SuperscriptTime'; @@ -46,9 +46,7 @@ export default function StudioClock(props: StudioClockProps) { const [searchParams] = useSearchParams(); - useEffect(() => { - document.title = 'ontime - Studio Clock'; - }, []); + useWindowTitle('Studio Clock'); let clock = formatTime(time.clock); let hasAmPm = ''; diff --git a/apps/client/src/features/viewers/timer/Timer.tsx b/apps/client/src/features/viewers/timer/Timer.tsx index 333606df6..2141877ad 100644 --- a/apps/client/src/features/viewers/timer/Timer.tsx +++ b/apps/client/src/features/viewers/timer/Timer.tsx @@ -1,4 +1,3 @@ -import { useEffect } from 'react'; import { useSearchParams } from 'react-router-dom'; import { AnimatePresence, motion } from 'framer-motion'; import { @@ -19,6 +18,7 @@ import TitleCard from '../../../common/components/title-card/TitleCard'; import { getTimerOptions } from '../../../common/components/view-params-editor/constants'; import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor'; import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet'; +import { useWindowTitle } from '../../../common/hooks/useWindowTitle'; import { ViewExtendedTimer } from '../../../common/models/TimeManager.type'; import { timerPlaceholder } from '../../../common/utils/styleUtils'; import { formatTime, getDefaultFormat } from '../../../common/utils/time'; @@ -63,9 +63,7 @@ export default function Timer(props: TimerProps) { const { getLocalizedString } = useTranslation(); const [searchParams] = useSearchParams(); - useEffect(() => { - document.title = 'ontime - Timer'; - }, []); + useWindowTitle('Timer'); // defer rendering until we load stylesheets if (!shouldRender) { diff --git a/e2e/tests/000-upload-showfile.spec.ts b/e2e/tests/000-upload-showfile.spec.ts index e87815b38..d642f27b8 100644 --- a/e2e/tests/000-upload-showfile.spec.ts +++ b/e2e/tests/000-upload-showfile.spec.ts @@ -2,7 +2,7 @@ import { test, expect } from '@playwright/test'; const fileToUpload = 'e2e/tests/fixtures/test-db.json'; -test('test project file upload', async ({ page }) => { +test('project file upload', async ({ page }) => { await page.goto('http://localhost:4001/editor'); await page.getByRole('button', { name: 'Rundown menu' }).click(); await page.getByRole('menuitem', { name: 'Delete all events' }).click(); diff --git a/e2e/tests/features/206-url-preset.spec.ts b/e2e/tests/features/206-url-preset.spec.ts index 750070d30..1c7451fce 100644 --- a/e2e/tests/features/206-url-preset.spec.ts +++ b/e2e/tests/features/206-url-preset.spec.ts @@ -1,10 +1,10 @@ -import { test } from '@playwright/test'; +import { expect, test } from '@playwright/test'; -test('test URL preset feature, it should redirect to given URL', async ({ page }) => { +test('URL preset feature, it should redirect to given URL', async ({ page }) => { await page.goto('http://localhost:4001/editor'); // open settings - await page.getByTestId('navigation__toggle-settings').click(); + await page.getByRole('button', { name: 'Toggle settings' }).click(); await page.getByRole('button', { name: 'General' }).click(); // create preset @@ -26,4 +26,5 @@ test('test URL preset feature, it should redirect to given URL', async ({ page } // make sure preset works await page.goto('http://localhost:4001/testing'); await page.getByTestId('countdown__select').click(); + await expect(page.getByTestId('countdown__select')).toBeVisible(); }); diff --git a/e2e/tests/features/301-spreadsheet-import.spec.ts b/e2e/tests/features/301-spreadsheet-import.spec.ts index 9dc70fdd7..5f56fc125 100644 --- a/e2e/tests/features/301-spreadsheet-import.spec.ts +++ b/e2e/tests/features/301-spreadsheet-import.spec.ts @@ -7,7 +7,7 @@ test('sheet file upload', async ({ page }) => { await page.getByRole('button', { name: 'Rundown menu' }).click(); await page.getByRole('menuitem', { name: 'Delete all events' }).click(); - await page.getByTestId('navigation__toggle-settings').click(); + await page.getByRole('button', { name: 'Toggle settings' }).click(); await page.getByRole('button', { name: 'Import spreadsheet' }).click(); // workaround to upload file on hidden input From 01000f8837538dd84de2ef60711008198c4c504b Mon Sep 17 00:00:00 2001 From: Alex Christoffer Rasmussen Date: Sun, 31 Mar 2024 22:07:42 +0200 Subject: [PATCH 2/9] feat: chaching for static react files (#862) * set immutable tag for static route * captur more server timings in NODE_ENV === 'development' --- apps/server/package.json | 1 + apps/server/src/app.ts | 11 ++++++++++- pnpm-lock.yaml | 15 ++++++++++++--- 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/apps/server/package.json b/apps/server/package.json index 3dc0c112c..513a292b4 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -43,6 +43,7 @@ "nodemon": "^2.0.20", "ontime-types": "workspace:*", "prettier": "^3.0.3", + "server-timing": "^3.3.3", "shx": "^0.3.4", "ts-node": "^10.9.1", "typescript": "^5.4.3", diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index afe3eb545..c93180ab7 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -5,6 +5,7 @@ import express from 'express'; import expressStaticGzip from 'express-static-gzip'; import http, { type Server } from 'http'; import cors from 'cors'; +import serverTiming from 'server-timing'; // import utils import { resolve } from 'path'; @@ -54,6 +55,10 @@ if (!isProduction) { // Create express APP const app = express(); +if (process.env.NODE_ENV === 'development') { + // log more serever timings + app.use(serverTiming()); +} app.disable('x-powered-by'); // setup cors for all routes @@ -83,11 +88,15 @@ app.use( expressStaticGzip(reactAppPath, { enableBrotli: true, orderPreference: ['br'], + // when we build the client all the react subfiles will get a hashed name we can the immutable tag + // as the contents of a build file will never change without also changing its name + // so the client dose not need to revalidate the file contetnts with the server + serveStatic: { etag: false, lastModified: false, immutable: true, maxAge: '1y' }, }), ); app.get('*', (_req, res) => { - res.sendFile(resolve(resolvedPath(), 'index.html')); + res.sendFile(resolve(reactAppPath, 'index.html')); }); // Implement catch all diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 31d545eea..e04f5c753 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -355,6 +355,9 @@ importers: prettier: specifier: ^3.0.3 version: 3.0.3 + server-timing: + specifier: ^3.3.3 + version: 3.3.3 shx: specifier: ^0.3.4 version: 0.3.4 @@ -7199,10 +7202,10 @@ packages: /minimist@1.2.7: resolution: {integrity: sha512-bzfL1YUZsP41gmu/qjrEk0Q6i2ix/cVeAhbCbqH9u3zYutS1cLg00qhrD0M2MVdCcx4Sc0UpP2eBWo9rotpq6g==} + dev: true /minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} - dev: true /minipass@3.3.6: resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==} @@ -7238,7 +7241,7 @@ packages: resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} hasBin: true dependencies: - minimist: 1.2.7 + minimist: 1.2.8 dev: false /mkdirp@1.0.4: @@ -7474,7 +7477,6 @@ packages: /on-headers@1.0.2: resolution: {integrity: sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==} engines: {node: '>= 0.8'} - dev: false /once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} @@ -8378,6 +8380,13 @@ packages: - supports-color dev: false + /server-timing@3.3.3: + resolution: {integrity: sha512-TP0xWAca4oM8H/PSdeaGgp2qm+HrZ2cWCRcMXS2t500a7Wum/hSojlpTW43VZsIUSVNlKPFGDknH34IqF+mbBg==} + dependencies: + minimist: 1.2.8 + on-headers: 1.0.2 + dev: true + /setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} dev: false From 10852eecdde41854acfc212354e861359d7553fa Mon Sep 17 00:00:00 2001 From: Carlos Valente <34649812+cpvalente@users.noreply.github.com> Date: Tue, 2 Apr 2024 20:09:27 +0200 Subject: [PATCH 3/9] Rundown modes (#864) * refactor: simplify settings * refactor: quick add around selection * refactor: no action menu in delay block * refactor: no action menu in event block * style: context menu dark * refactor: context menu actions --- .../src/common/stores/editorSettings.ts | 11 -- .../interface-panel/EditorSettingsForm.tsx | 124 +++++++++++------- apps/client/src/features/rundown/Rundown.tsx | 16 ++- .../src/features/rundown/RundownEntry.tsx | 32 ++++- .../rundown/block-block/BlockBlock.tsx | 35 ++--- .../rundown/delay-block/DelayBlock.tsx | 16 +-- .../event-block/EventBlock.module.scss | 9 +- .../rundown/event-block/EventBlock.tsx | 53 ++++---- .../rundown/event-block/EventBlockInner.tsx | 7 - .../event-block/composite/BlockActionMenu.tsx | 64 --------- .../quick-add-block/QuickAddBlock.module.scss | 2 +- .../rundown/quick-add-block/QuickAddBlock.tsx | 28 ++-- .../rundown/rundown-header/RundownHeader.tsx | 20 +-- .../rundown/rundown-header/RundownMenu.tsx | 59 +++------ apps/client/src/theme/ontimeCheckbox.ts | 14 +- apps/client/src/theme/ontimeMenu.ts | 22 +++- apps/client/src/theme/theme.ts | 4 +- e2e/tests/000-upload-showfile.spec.ts | 4 +- e2e/tests/features/203-delay-block.spec.ts | 19 ++- e2e/tests/features/204-editor-crud.spec.ts | 21 ++- e2e/tests/features/205-operator.spec.ts | 43 +++--- .../features/301-spreadsheet-import.spec.ts | 4 +- 22 files changed, 276 insertions(+), 331 deletions(-) delete mode 100644 apps/client/src/features/rundown/event-block/composite/BlockActionMenu.tsx diff --git a/apps/client/src/common/stores/editorSettings.ts b/apps/client/src/common/stores/editorSettings.ts index b5e8dafd4..0a74311f0 100644 --- a/apps/client/src/common/stores/editorSettings.ts +++ b/apps/client/src/common/stores/editorSettings.ts @@ -3,7 +3,6 @@ import { create } from 'zustand'; import { booleanFromLocalStorage } from '../utils/localStorage'; type EditorSettings = { - showQuickEntry: boolean; linkPrevious: boolean; defaultPublic: boolean; defaultDuration: string; @@ -12,14 +11,12 @@ type EditorSettings = { type EditorSettingsStore = { eventSettings: EditorSettings; setLocalEventSettings: (newState: EditorSettings) => void; - setShowQuickEntry: (showQuickEntry: boolean) => void; setLinkPrevious: (linkPrevious: boolean) => void; setDefaultPublic: (defaultPublic: boolean) => void; setDefaultDuration: (defaultDuration: string) => void; }; enum EditorSettingsKeys { - ShowQuickEntry = 'ontime-show-quick-entry', LinkPrevious = 'ontime-link-previous', DefaultPublic = 'ontime-default-public', DefaultDuration = 'ontime-default-duration', @@ -27,7 +24,6 @@ enum EditorSettingsKeys { export const useEditorSettings = create((set) => ({ eventSettings: { - showQuickEntry: booleanFromLocalStorage(EditorSettingsKeys.ShowQuickEntry, false), linkPrevious: booleanFromLocalStorage(EditorSettingsKeys.LinkPrevious, true), defaultPublic: booleanFromLocalStorage(EditorSettingsKeys.DefaultPublic, true), defaultDuration: localStorage.getItem(EditorSettingsKeys.DefaultDuration) ?? '00:10:00', @@ -35,18 +31,11 @@ export const useEditorSettings = create((set) => ({ setLocalEventSettings: (value) => set(() => { - localStorage.setItem(EditorSettingsKeys.ShowQuickEntry, String(value.showQuickEntry)); localStorage.setItem(EditorSettingsKeys.LinkPrevious, String(value.linkPrevious)); localStorage.setItem(EditorSettingsKeys.DefaultPublic, String(value.defaultPublic)); return { eventSettings: value }; }), - setShowQuickEntry: (showQuickEntry) => - set((state) => { - localStorage.setItem(EditorSettingsKeys.ShowQuickEntry, String(showQuickEntry)); - return { eventSettings: { ...state.eventSettings, showQuickEntry } }; - }), - setLinkPrevious: (linkPrevious) => set((state) => { localStorage.setItem(EditorSettingsKeys.LinkPrevious, String(linkPrevious)); diff --git a/apps/client/src/features/app-settings/panel/interface-panel/EditorSettingsForm.tsx b/apps/client/src/features/app-settings/panel/interface-panel/EditorSettingsForm.tsx index 29f7d1f3f..5130dd19f 100644 --- a/apps/client/src/features/app-settings/panel/interface-panel/EditorSettingsForm.tsx +++ b/apps/client/src/features/app-settings/panel/interface-panel/EditorSettingsForm.tsx @@ -7,7 +7,6 @@ import * as Panel from '../PanelUtils'; export default function EditorSettingsForm() { const eventSettings = useEditorSettings((state) => state.eventSettings); - const setShowQuickEntry = useEditorSettings((state) => state.setShowQuickEntry); const setLinkPrevious = useEditorSettings((state) => state.setLinkPrevious); const setDefaultPublic = useEditorSettings((state) => state.setDefaultPublic); const setDefaultDuration = useEditorSettings((state) => state.setDefaultDuration); @@ -19,53 +18,82 @@ export default function EditorSettingsForm() { Editor settings - - - - setShowQuickEntry(event.target.checked)} - /> - - - - setLinkPrevious(event.target.checked)} - /> - - - - - name='defaultDuration' - submitHandler={(_field, value) => setDefaultDuration(value)} - time={durationInMs} - placeholder='00:10:00' - /> - - - - setDefaultPublic(event.target.checked)} - /> - - + + Rundown options + + + + + name='defaultDuration' + submitHandler={(_field, value) => setDefaultDuration(value)} + time={durationInMs} + placeholder='00:10:00' + /> + + + + setLinkPrevious(event.target.checked)} + /> + + + + setDefaultPublic(event.target.checked)} + /> + + + + + Play mode + + + + + + + + + + + + + Edit mode + + + + + + + + + + + ); diff --git a/apps/client/src/features/rundown/Rundown.tsx b/apps/client/src/features/rundown/Rundown.tsx index 4c7df353c..3d059d901 100644 --- a/apps/client/src/features/rundown/Rundown.tsx +++ b/apps/client/src/features/rundown/Rundown.tsx @@ -39,7 +39,6 @@ export default function Rundown({ data }: RundownProps) { const eventSettings = useEditorSettings((state) => state.eventSettings); const defaultPublic = eventSettings.defaultPublic; const linkPrevious = eventSettings.linkPrevious; - const showQuickEntry = eventSettings.showQuickEntry; // cursor const { cursor, mode: appMode, setCursor } = useAppMode(); @@ -219,6 +218,8 @@ export default function Rundown({ data }: RundownProps) { // all events before the current selected are in the past let isPast = Boolean(featureData?.selectedEventId); + const isEditMode = appMode === AppMode.Edit; + return (
@@ -248,6 +249,7 @@ export default function Rundown({ data }: RundownProps) { thisId = eventId; } } + const isFirst = index === 0; const isLast = index === order.length - 1; const isLoaded = featureData?.selectedEventId === event.id; const isNext = featureData?.nextEventId === event.id; @@ -258,6 +260,14 @@ export default function Rundown({ data }: RundownProps) { return ( + {isEditMode && (hasCursor || isFirst) && ( + + )}
{isOntimeEvent(event) &&
{eventIndex}
}
@@ -277,9 +287,9 @@ export default function Rundown({ data }: RundownProps) { />
- {((showQuickEntry && hasCursor) || isLast) && ( + {isEditMode && (hasCursor || isLast) && ( ); } else if (data.type === SupportedEvent.Block) { - return ; + return actionHandler('delete')} />; } else if (data.type === SupportedEvent.Delay) { - return ; + return ; } return null; } diff --git a/apps/client/src/features/rundown/block-block/BlockBlock.tsx b/apps/client/src/features/rundown/block-block/BlockBlock.tsx index c1248664e..16dc3da4f 100644 --- a/apps/client/src/features/rundown/block-block/BlockBlock.tsx +++ b/apps/client/src/features/rundown/block-block/BlockBlock.tsx @@ -1,32 +1,24 @@ -import { useEffect, useRef } from 'react'; +import { useRef } from 'react'; +import { IconButton } from '@chakra-ui/react'; import { useSortable } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import { IoReorderTwo } from '@react-icons/all-files/io5/IoReorderTwo'; -import { OntimeBlock, OntimeEvent } from 'ontime-types'; +import { IoTrash } from '@react-icons/all-files/io5/IoTrash'; +import { OntimeBlock } from 'ontime-types'; import { cx } from '../../../common/utils/styleUtils'; import EditableBlockTitle from '../common/EditableBlockTitle'; -import BlockActionMenu from '../event-block/composite/BlockActionMenu'; -import type { EventItemActions } from '../RundownEntry'; import style from './BlockBlock.module.scss'; interface BlockBlockProps { data: OntimeBlock; hasCursor: boolean; - actionHandler: ( - action: EventItemActions, - payload?: - | number - | { - field: keyof Omit | 'durationOverride'; - value: unknown; - }, - ) => void; + onDelete: () => void; } export default function BlockBlock(props: BlockBlockProps) { - const { data, hasCursor, actionHandler } = props; + const { data, hasCursor, onDelete } = props; const handleRef = useRef(null); const { @@ -45,12 +37,6 @@ export default function BlockBlock(props: BlockBlockProps) { transition, }; - useEffect(() => { - if (hasCursor) { - handleRef?.current?.focus(); - } - }, [hasCursor]); - const blockClasses = cx([style.block, hasCursor ? style.hasCursor : null]); return ( @@ -59,7 +45,14 @@ export default function BlockBlock(props: BlockBlockProps) { - + } + variant='ontime-subtle' + color='#FA5656' + onClick={onDelete} + />
); } diff --git a/apps/client/src/features/rundown/delay-block/DelayBlock.tsx b/apps/client/src/features/rundown/delay-block/DelayBlock.tsx index 136035375..e99f2a491 100644 --- a/apps/client/src/features/rundown/delay-block/DelayBlock.tsx +++ b/apps/client/src/features/rundown/delay-block/DelayBlock.tsx @@ -5,32 +5,21 @@ import { CSS } from '@dnd-kit/utilities'; import { IoCheckmark } from '@react-icons/all-files/io5/IoCheckmark'; import { IoClose } from '@react-icons/all-files/io5/IoClose'; import { IoReorderTwo } from '@react-icons/all-files/io5/IoReorderTwo'; -import { OntimeDelay, OntimeEvent } from 'ontime-types'; +import { OntimeDelay } from 'ontime-types'; import DelayInput from '../../../common/components/input/delay-input/DelayInput'; import { useEventAction } from '../../../common/hooks/useEventAction'; import { cx } from '../../../common/utils/styleUtils'; -import BlockActionMenu from '../event-block/composite/BlockActionMenu'; -import type { EventItemActions } from '../RundownEntry'; import style from './DelayBlock.module.scss'; interface DelayBlockProps { data: OntimeDelay; hasCursor: boolean; - actionHandler: ( - action: EventItemActions, - payload?: - | number - | { - field: keyof Omit | 'durationOverride'; - value: unknown; - }, - ) => void; } export default function DelayBlock(props: DelayBlockProps) { - const { data, hasCursor, actionHandler } = props; + const { data, hasCursor } = props; const { applyDelay, deleteEvent } = useEventAction(); const handleRef = useRef(null); @@ -79,7 +68,6 @@ export default function DelayBlock(props: DelayBlockProps) { -
); diff --git a/apps/client/src/features/rundown/event-block/EventBlock.module.scss b/apps/client/src/features/rundown/event-block/EventBlock.module.scss index 8f66bff92..2328b52e0 100644 --- a/apps/client/src/features/rundown/event-block/EventBlock.module.scss +++ b/apps/client/src/features/rundown/event-block/EventBlock.module.scss @@ -9,7 +9,7 @@ $skip-opacity: 0.2; display: grid; grid-template-areas: 'binder ... ... ...' - 'binder pb-actions times actions' + 'binder pb-actions times ...' 'binder pb-actions title title' 'binder pb-actions estatus estatus' 'binder ... ... ...'; @@ -55,7 +55,6 @@ $skip-opacity: 0.2; outline: 1px solid $block-cursor-color; } - /* we stop the eventActions from having opacity to fix issue with dropdown drawing order */ &.past:not(.skip) { .timerNote, .statusElements, @@ -153,12 +152,6 @@ $skip-opacity: 0.2; } } -.eventActions { - grid-area: actions; - height: 100%; - text-align: end; -} - .progressBg { grid-area: progb; border-radius: 1px; diff --git a/apps/client/src/features/rundown/event-block/EventBlock.tsx b/apps/client/src/features/rundown/event-block/EventBlock.tsx index 67a84ab3e..907399acc 100644 --- a/apps/client/src/features/rundown/event-block/EventBlock.tsx +++ b/apps/client/src/features/rundown/event-block/EventBlock.tsx @@ -2,16 +2,18 @@ import { MouseEvent, useEffect, useLayoutEffect, useRef, useState } from 'react' import { useSortable } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import { IoAdd } from '@react-icons/all-files/io5/IoAdd'; -import { IoCopyOutline } from '@react-icons/all-files/io5/IoCopyOutline'; +import { IoDuplicateOutline } from '@react-icons/all-files/io5/IoDuplicateOutline'; import { IoPeople } from '@react-icons/all-files/io5/IoPeople'; import { IoPeopleOutline } from '@react-icons/all-files/io5/IoPeopleOutline'; +import { IoRemoveCircleOutline } from '@react-icons/all-files/io5/IoRemoveCircleOutline'; import { IoReorderTwo } from '@react-icons/all-files/io5/IoReorderTwo'; import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical'; +import { IoTimerOutline } from '@react-icons/all-files/io5/IoTimerOutline'; +import { IoTrash } from '@react-icons/all-files/io5/IoTrash'; import { EndAction, MaybeNumber, MaybeString, OntimeEvent, Playback, TimerType, TimeStrategy } from 'ontime-types'; import { useContextMenu } from '../../../common/hooks/useContextMenu'; import { useAppMode } from '../../../common/stores/appModeStore'; -import copyToClipboard from '../../../common/utils/copyToClipboard'; import { cx, getAccessibleColour } from '../../../common/utils/styleUtils'; import type { EventItemActions } from '../RundownEntry'; import { useEventIdSwapping } from '../useEventIdSwapping'; @@ -96,31 +98,25 @@ export default function EventBlock(props: EventBlockProps) { selectedEvents.size > 1 ? [ { - label: 'Visiblity', - group: [ - { - label: 'Make public', - icon: IoPeople, - onClick: () => - actionHandler('update', { - field: 'isPublic', - value: true, - }), - }, - { - label: 'Make private', - icon: IoPeopleOutline, - onClick: () => - actionHandler('update', { - field: 'isPublic', - value: false, - }), - }, - ], + label: 'Make public', + icon: IoPeople, + onClick: () => + actionHandler('update', { + field: 'isPublic', + value: true, + }), + }, + { + label: 'Make private', + icon: IoPeopleOutline, + onClick: () => + actionHandler('update', { + field: 'isPublic', + value: false, + }), }, ] : [ - { label: `Copy ID: ${eventId}`, icon: IoCopyOutline, onClick: () => copyToClipboard(eventId) }, { label: 'Toggle public', icon: IoPeopleOutline, @@ -145,6 +141,14 @@ export default function EventBlock(props: EventBlockProps) { }, isDisabled: selectedEventId == null || selectedEventId === eventId, }, + { withDivider: true, label: 'Clone', icon: IoDuplicateOutline, onClick: () => actionHandler('clone') }, + { withDivider: true, label: 'Event before', icon: IoAdd, onClick: () => actionHandler('event-before') }, + { label: 'Event after', icon: IoAdd, onClick: () => actionHandler('event') }, + { label: 'Block before', icon: IoRemoveCircleOutline, onClick: () => actionHandler('block-before') }, + { label: 'Block after', icon: IoRemoveCircleOutline, onClick: () => actionHandler('block') }, + { label: 'Delay before', icon: IoTimerOutline, onClick: () => actionHandler('delay-before') }, + { label: 'Delay after', icon: IoTimerOutline, onClick: () => actionHandler('delay') }, + { withDivider: true, label: 'Delete', icon: IoTrash, onClick: () => actionHandler('delete') }, ], ); @@ -281,7 +285,6 @@ export default function EventBlock(props: EventBlockProps) { loaded={loaded} playback={playback} isRolling={isRolling} - actionHandler={actionHandler} /> )}
diff --git a/apps/client/src/features/rundown/event-block/EventBlockInner.tsx b/apps/client/src/features/rundown/event-block/EventBlockInner.tsx index e279ddba3..827fe3052 100644 --- a/apps/client/src/features/rundown/event-block/EventBlockInner.tsx +++ b/apps/client/src/features/rundown/event-block/EventBlockInner.tsx @@ -14,10 +14,8 @@ import { EndAction, MaybeString, Playback, TimerType, TimeStrategy } from 'ontim import { cx } from '../../../common/utils/styleUtils'; import { tooltipDelayMid } from '../../../ontimeConfig'; import EditableBlockTitle from '../common/EditableBlockTitle'; -import { EventItemActions } from '../RundownEntry'; import TimeInputFlow from '../time-input-flow/TimeInputFlow'; -import BlockActionMenu from './composite/BlockActionMenu'; import EventBlockPlayback from './composite/EventBlockPlayback'; import EventBlockProgressBar from './composite/EventBlockProgressBar'; @@ -46,7 +44,6 @@ interface EventBlockInnerProps { loaded: boolean; playback?: Playback; isRolling: boolean; - actionHandler: (action: EventItemActions, payload?: any) => void; } const EventBlockInner = (props: EventBlockInnerProps) => { @@ -68,7 +65,6 @@ const EventBlockInner = (props: EventBlockInnerProps) => { loaded, playback, isRolling, - actionHandler, } = props; const [renderInner, setRenderInner] = useState(false); @@ -139,9 +135,6 @@ const EventBlockInner = (props: EventBlockInnerProps) => {
-
- -
); }; diff --git a/apps/client/src/features/rundown/event-block/composite/BlockActionMenu.tsx b/apps/client/src/features/rundown/event-block/composite/BlockActionMenu.tsx deleted file mode 100644 index 04452cfa3..000000000 --- a/apps/client/src/features/rundown/event-block/composite/BlockActionMenu.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { useCallback } from 'react'; -import { IconButton, Menu, MenuButton, MenuDivider, MenuItem, MenuList, Tooltip } from '@chakra-ui/react'; -import { IoAdd } from '@react-icons/all-files/io5/IoAdd'; -import { IoDuplicateOutline } from '@react-icons/all-files/io5/IoDuplicateOutline'; -import { IoEllipsisHorizontal } from '@react-icons/all-files/io5/IoEllipsisHorizontal'; -import { IoRemoveCircleOutline } from '@react-icons/all-files/io5/IoRemoveCircleOutline'; -import { IoTimerOutline } from '@react-icons/all-files/io5/IoTimerOutline'; -import { IoTrashBinSharp } from '@react-icons/all-files/io5/IoTrashBinSharp'; - -import { tooltipDelayMid } from '../../../../ontimeConfig'; -import { EventItemActions } from '../../RundownEntry'; - -interface BlockActionMenuProps { - enableDelete?: boolean; - showClone?: boolean; - actionHandler: (action: EventItemActions, payload?: any) => void; - className?: string; -} - -export default function BlockActionMenu(props: BlockActionMenuProps) { - const { enableDelete, showClone, actionHandler, className } = props; - - const handleAddEvent = useCallback(() => actionHandler('event'), [actionHandler]); - const handleAddDelay = useCallback(() => actionHandler('delay'), [actionHandler]); - const handleAddBlock = useCallback(() => actionHandler('block'), [actionHandler]); - const handleClone = useCallback(() => actionHandler('clone'), [actionHandler]); - const handleDelete = useCallback(() => actionHandler('delete'), [actionHandler]); - - return ( - - - } - tabIndex={-1} - variant='ontime-ghosted-white' - size='sm' - className={className} - /> - - - } onClick={handleAddEvent}> - Add Event after - - } onClick={handleAddDelay}> - Add Delay after - - } onClick={handleAddBlock}> - Add Block after - - {showClone && ( - } onClick={handleClone}> - Clone event - - )} - - } onClick={handleDelete} isDisabled={!enableDelete} color='#D20300'> - Delete - - - - ); -} diff --git a/apps/client/src/features/rundown/quick-add-block/QuickAddBlock.module.scss b/apps/client/src/features/rundown/quick-add-block/QuickAddBlock.module.scss index 4a95ad6f0..e5b146389 100644 --- a/apps/client/src/features/rundown/quick-add-block/QuickAddBlock.module.scss +++ b/apps/client/src/features/rundown/quick-add-block/QuickAddBlock.module.scss @@ -23,7 +23,7 @@ padding: 0 0.25rem; color: $label-gray; border-radius: 2px; - background-color: $black-10 + background-color: $black-10; } .options { diff --git a/apps/client/src/features/rundown/quick-add-block/QuickAddBlock.tsx b/apps/client/src/features/rundown/quick-add-block/QuickAddBlock.tsx index f85cf6e91..90f58a0fa 100644 --- a/apps/client/src/features/rundown/quick-add-block/QuickAddBlock.tsx +++ b/apps/client/src/features/rundown/quick-add-block/QuickAddBlock.tsx @@ -12,14 +12,14 @@ import { tooltipDelayMid } from '../../../ontimeConfig'; import style from './QuickAddBlock.module.scss'; interface QuickAddBlockProps { - showKbd: boolean; - previousEventId: string; + showKbd: 'above' | 'below' | 'none'; + previousEventId?: string; disableAddDelay?: boolean; disableAddBlock: boolean; } const QuickAddBlock = (props: QuickAddBlockProps) => { - const { showKbd, previousEventId, disableAddDelay = true, disableAddBlock } = props; + const { showKbd = 'none', previousEventId, disableAddDelay = true, disableAddBlock } = props; const { addEvent } = useEventAction(); const { emitError } = useEmitLog(); @@ -28,6 +28,8 @@ const QuickAddBlock = (props: QuickAddBlockProps) => { const { defaultPublic, linkPrevious } = useEditorSettings((state) => state.eventSettings); + const shortcutBase = showKbd === 'none' ? '' : `${deviceAlt} ${showKbd === 'above' ? '⇧' : ''}`; + const handleCreateEvent = useCallback( (eventType: SupportedEvent) => { switch (eventType) { @@ -70,6 +72,9 @@ const QuickAddBlock = (props: QuickAddBlockProps) => { [previousEventId, addEvent, emitError], ); + const canLinkPrevious = Boolean(previousEventId); + const shouldLinkPrevious = Boolean(linkPrevious) && canLinkPrevious; + return (
@@ -79,10 +84,9 @@ const QuickAddBlock = (props: QuickAddBlockProps) => { size='xs' variant='ontime-subtle-white' className={style.quickBtn} - data-testid='quick-add-event' leftIcon={} > - Event {showKbd && {`${deviceAlt} + E`}} + Event {shortcutBase && {`${shortcutBase} E`}} @@ -92,10 +96,9 @@ const QuickAddBlock = (props: QuickAddBlockProps) => { variant='ontime-subtle-white' disabled={disableAddDelay} className={style.quickBtn} - data-testid='quick-add-delay' leftIcon={} > - Delay {showKbd && {`${deviceAlt} + D`}} + Delay {shortcutBase && {`${shortcutBase} D`}} @@ -105,15 +108,20 @@ const QuickAddBlock = (props: QuickAddBlockProps) => { variant='ontime-subtle-white' disabled={disableAddBlock} className={style.quickBtn} - data-testid='quick-add-block' leftIcon={} > - Block {showKbd && {`${deviceAlt} + B`}} + Block {shortcutBase && {`${shortcutBase} B`}}
- + Link to previous diff --git a/apps/client/src/features/rundown/rundown-header/RundownHeader.tsx b/apps/client/src/features/rundown/rundown-header/RundownHeader.tsx index 6875c1fb0..f30635196 100644 --- a/apps/client/src/features/rundown/rundown-header/RundownHeader.tsx +++ b/apps/client/src/features/rundown/rundown-header/RundownHeader.tsx @@ -1,8 +1,6 @@ -import { Button, ButtonGroup, MenuButton } from '@chakra-ui/react'; -import { IoAdd } from '@react-icons/all-files/io5/IoAdd'; +import { ButtonGroup } from '@chakra-ui/react'; import { IoOptions } from '@react-icons/all-files/io5/IoOptions'; import { IoPlay } from '@react-icons/all-files/io5/IoPlay'; -import { IoSnowOutline } from '@react-icons/all-files/io5/IoSnowOutline'; import TooltipActionBtn from '../../../common/components/buttons/TooltipActionBtn'; import { AppMode, useAppMode } from '../../../common/stores/appModeStore'; @@ -16,20 +14,10 @@ export default function RundownHeader() { const setAppMode = useAppMode((state) => state.setMode); const setRunMode = () => setAppMode(AppMode.Run); const setEditMode = () => setAppMode(AppMode.Edit); - const setFreezeMode = () => setAppMode(AppMode.Freeze); return (
- } - clickHandler={setFreezeMode} - tooltip='Freeze rundown' - aria-label='Freeze rundown' - isDisabled - /> - - } aria-label='Rundown menu' variant='ontime-outlined'> - Rundown - - +
); } diff --git a/apps/client/src/features/rundown/rundown-header/RundownMenu.tsx b/apps/client/src/features/rundown/rundown-header/RundownMenu.tsx index ef409e266..12493d8b2 100644 --- a/apps/client/src/features/rundown/rundown-header/RundownMenu.tsx +++ b/apps/client/src/features/rundown/rundown-header/RundownMenu.tsx @@ -1,31 +1,16 @@ -import { memo, ReactNode, useCallback } from 'react'; -import { Menu, MenuDivider, MenuItem, MenuList } from '@chakra-ui/react'; -import { IoAdd } from '@react-icons/all-files/io5/IoAdd'; -import { IoRemoveCircleOutline } from '@react-icons/all-files/io5/IoRemoveCircleOutline'; -import { IoTimerOutline } from '@react-icons/all-files/io5/IoTimerOutline'; -import { IoTrashOutline } from '@react-icons/all-files/io5/IoTrashOutline'; -import { SupportedEvent } from 'ontime-types'; +import { useCallback } from 'react'; +import { Button } from '@chakra-ui/react'; +import { IoTrash } from '@react-icons/all-files/io5/IoTrash'; import { useEventAction } from '../../../common/hooks/useEventAction'; import { useAppMode } from '../../../common/stores/appModeStore'; import { useEventSelection } from '../useEventSelection'; -const RundownMenu = ({ children }: { children: ReactNode }) => { +export default function RundownMenu() { const clearSelectedEvents = useEventSelection((state) => state.clearSelectedEvents); const setCursor = useAppMode((state) => state.setCursor); - const { addEvent, deleteAllEvents } = useEventAction(); - - const newEvent = useCallback(() => { - addEvent({ type: SupportedEvent.Event }); - }, [addEvent]); - - const newBlock = useCallback(() => { - addEvent({ type: SupportedEvent.Block }); - }, [addEvent]); - - const newDelay = useCallback(() => { - addEvent({ type: SupportedEvent.Delay }); - }, [addEvent]); + const appMode = useAppMode((state) => state.mode); + const { deleteAllEvents } = useEventAction(); const deleteAll = useCallback(() => { deleteAllEvents(); @@ -34,25 +19,15 @@ const RundownMenu = ({ children }: { children: ReactNode }) => { }, [clearSelectedEvents, deleteAllEvents, setCursor]); return ( - - {children} - - } onClick={newEvent}> - Add event at start - - } onClick={newDelay}> - Add delay at start - - } onClick={newBlock}> - Add block at start - - - } onClick={deleteAll} color='#D20300'> - Delete all events - - - + ); -}; - -export default memo(RundownMenu); +} diff --git a/apps/client/src/theme/ontimeCheckbox.ts b/apps/client/src/theme/ontimeCheckbox.ts index 003937d95..345c928fb 100644 --- a/apps/client/src/theme/ontimeCheckbox.ts +++ b/apps/client/src/theme/ontimeCheckbox.ts @@ -3,12 +3,24 @@ export const ontimeCheckboxOnDark = { border: '1px', borderColor: '#2d2d2d', // $gray-1100 backgroundColor: '#2d2d2d', // $gray-1100 + _disabled: { + color: 'white', + borderColor: '#2d2d2d', // $gray-1100 + backgroundColor: '#2d2d2d', // $gray-1100 + opacity: 0.6, + }, _checked: { borderColor: '#3182ce', // $action-blue backgroundColor: '#3182ce', //$action-blue + _disabled: { + color: 'white', + borderColor: '#3182ce', // $action-blue + backgroundColor: '#3182ce', //$action-blue + opacity: 0.6, + }, }, _focus: { - boxShadow: '0 0 0 1px #578AF4', // $blue-500 + boxShadow: 'none', }, }, label: { diff --git a/apps/client/src/theme/ontimeMenu.ts b/apps/client/src/theme/ontimeMenu.ts index b38ddebad..59f01daa4 100644 --- a/apps/client/src/theme/ontimeMenu.ts +++ b/apps/client/src/theme/ontimeMenu.ts @@ -1,19 +1,27 @@ export const ontimeMenuOnDark = { list: { + fontSize: 'calc(1rem - 2px)', borderRadius: '3px', - border: 'none', - bg: '#fff', // $gray-50 + borderColor: 'rgba(255, 255, 255, 0.1)', + color: '#ececec', // $gray-1030 + backgroundColor: '#202020', // $gray-1250 zIndex: 100, }, item: { - letterSpacing: '0.15px', - color: '#101010', // $gray-1350 - bg: '#fff', // + backgroundColor: 'transparent', + paddingBlock: '0.5rem', _hover: { - backgroundColor: '#e2e2e2', // $gray-200 + backgroundColor: 'rgba(0, 0, 0, 0.1)', + _disabled: { + backgroundColor: 'transparent', + }, + }, + _disabled: { + color: '#b1b1b1', // $gray-400 }, }, divider: { - borderColor: '#cfcfcf', // $gray-200 + borderColor: 'rgba(255, 255, 255, 0.07)', + opacity: 1, }, }; diff --git a/apps/client/src/theme/theme.ts b/apps/client/src/theme/theme.ts index a34c6cb8b..7681c3bdf 100644 --- a/apps/client/src/theme/theme.ts +++ b/apps/client/src/theme/theme.ts @@ -57,8 +57,8 @@ const theme = extendTheme({ }, Drawer: { variants: { - 'ontime': {...ontimeDrawer}, - } + ontime: { ...ontimeDrawer }, + }, }, Editable: { variants: { diff --git a/e2e/tests/000-upload-showfile.spec.ts b/e2e/tests/000-upload-showfile.spec.ts index d642f27b8..fa3de721a 100644 --- a/e2e/tests/000-upload-showfile.spec.ts +++ b/e2e/tests/000-upload-showfile.spec.ts @@ -4,8 +4,8 @@ const fileToUpload = 'e2e/tests/fixtures/test-db.json'; test('project file upload', async ({ page }) => { await page.goto('http://localhost:4001/editor'); - await page.getByRole('button', { name: 'Rundown menu' }).click(); - await page.getByRole('menuitem', { name: 'Delete all events' }).click(); + await page.getByRole('button', { name: 'Edit mode' }).click(); + await page.getByRole('button', { name: 'Clear rundown' }).click(); await page.getByRole('button', { name: 'toggle settings' }).click(); await page.getByRole('button', { name: 'Project', exact: true }).click(); diff --git a/e2e/tests/features/203-delay-block.spec.ts b/e2e/tests/features/203-delay-block.spec.ts index 6b5030be7..97e2e2f13 100644 --- a/e2e/tests/features/203-delay-block.spec.ts +++ b/e2e/tests/features/203-delay-block.spec.ts @@ -4,9 +4,8 @@ test('delay blocks add time to events', async ({ page }) => { await page.goto('http://localhost:4001/editor'); // delete all events and add a new one - await page.getByRole('button', { name: 'Rundown menu' }).click(); - await page.getByRole('menuitem', { name: 'Delete all events' }).click(); - await page.getByRole('button', { name: 'Rundown menu' }).click(); + await page.getByRole('button', { name: 'Edit mode' }).click(); + await page.getByRole('button', { name: 'Clear rundown' }).click(); await page.getByRole('button', { name: 'Create event' }).click(); // add data to new event @@ -18,8 +17,7 @@ test('delay blocks add time to events', async ({ page }) => { await page.getByTestId('rundown').getByPlaceholder('Duration').press('Enter'); // add delay block - await page.getByRole('button', { name: 'Rundown menu' }).click(); - await page.getByRole('menuitem', { name: 'Add delay at start' }).click(); + await page.getByRole('button', { name: 'Delay Alt ⇧ D' }).click(); // fill positive delay await page.getByTestId('delay-input').click(); @@ -37,8 +35,7 @@ test('delay blocks add time to events', async ({ page }) => { // add new delay await page.getByTestId('rundown').getByPlaceholder('Start').click(); - await page.getByRole('button', { name: 'Rundown menu' }).click(); - await page.getByRole('menuitem', { name: 'Add delay at start' }).click(); + await page.getByRole('button', { name: 'Delay Alt ⇧ D' }).click(); await page.getByTestId('delay-input').click(); await page.getByTestId('delay-input').fill('10m'); await page.getByTestId('delay-input').press('Enter'); @@ -54,9 +51,10 @@ test('delays are show correctly', async ({ page }) => { await page.goto('http://localhost:4001/editor'); // add a test event - await page.getByRole('button', { name: 'Rundown menu' }).click(); - await page.getByRole('menuitem', { name: 'Delete all events' }).click(); + await page.getByRole('button', { name: 'Edit mode' }).click(); + await page.getByRole('button', { name: 'Clear rundown' }).click(); await page.getByRole('button', { name: 'Create Event' }).click(); + await page.getByTestId('time-input-timeStart').click(); await page.getByTestId('rundown').getByTestId('time-input-timeStart').click(); await page.getByTestId('rundown').getByTestId('time-input-timeStart').fill('10'); @@ -70,8 +68,7 @@ test('delays are show correctly', async ({ page }) => { await expect(page.getByTestId('entry-1').locator('#block-status')).toHaveAttribute('data-ispublic', 'true'); // add a delay - await page.getByRole('button', { name: 'Rundown menu' }).click(); - await page.getByRole('menuitem', { name: 'Add delay at start' }).click(); + await page.getByRole('button', { name: 'Delay Alt ⇧ D' }).click(); await page.getByTestId('delay-input').click(); await page.getByTestId('delay-input').fill('1'); await page.getByTestId('delay-input').press('Enter'); diff --git a/e2e/tests/features/204-editor-crud.spec.ts b/e2e/tests/features/204-editor-crud.spec.ts index e37b4871f..ab5f33915 100644 --- a/e2e/tests/features/204-editor-crud.spec.ts +++ b/e2e/tests/features/204-editor-crud.spec.ts @@ -4,27 +4,26 @@ test('CRUD operations on the rundown', async ({ page }) => { await page.goto('http://localhost:4001/editor'); // clear rundown - await page.getByRole('button', { name: 'Run mode' }).click(); - await page.getByRole('button', { name: 'Rundown menu' }).click(); - await page.getByRole('menuitem', { name: 'Delete all events' }).click(); + await page.getByRole('button', { name: 'Edit mode' }).click(); + await page.getByRole('button', { name: 'Clear rundown' }).click(); // create event from the rundown empty button await page.getByRole('button', { name: 'Create Event' }).click(); // create blocks using the quick add buttons - await page.getByRole('button', { name: 'Block' }).click(); - await page.getByRole('button', { name: 'Delay' }).click(); - await page.getByTestId('quick-add-event').click(); + await page.getByRole('button', { name: 'Block' }).nth(1).click(); + await page.getByRole('button', { name: 'Delay' }).nth(1).click(); + await page.getByRole('button', { name: 'Event', exact: true }).nth(1).click(); - // test quick add options - start is last end + // test quick add options - star2+5-t is last end await page.getByTestId('entry-2').getByTestId('time-input-duration').fill('20m'); - await page.getByTestId('quick-add-event').click(); - await expect(page.getByLabel('Link to previous')).toBeChecked(); + await page.getByRole('button', { name: 'Event', exact: true }).nth(1).click(); + await expect(page.getByLabel('Link to previous').nth(1)).toBeChecked(); expect(await page.getByTestId('entry-3').getByTestId('time-input-timeStart').inputValue()).toContain('00:30:00'); // test quick add options - event is public - await expect(page.getByLabel('Event is public')).toBeChecked(); - await page.getByTestId('quick-add-event').click(); + await expect(page.getByLabel('Event is public').nth(1)).toBeChecked(); + await page.getByRole('button', { name: 'Event', exact: true }).nth(1).click(); await expect(page.getByTestId('entry-4').locator('#block-status')).toHaveAttribute('data-ispublic', 'true'); }); diff --git a/e2e/tests/features/205-operator.spec.ts b/e2e/tests/features/205-operator.spec.ts index 8bdf603e8..8dfb704f5 100644 --- a/e2e/tests/features/205-operator.spec.ts +++ b/e2e/tests/features/205-operator.spec.ts @@ -3,9 +3,8 @@ import { test, expect } from '@playwright/test'; test('smoke test operator', async ({ page }) => { // make some boilerplate await page.goto('http://localhost:4001/editor'); - await page.getByRole('button', { name: 'Run mode' }).click(); - await page.getByRole('button', { name: 'Rundown menu' }).click(); - await page.getByRole('menuitem', { name: 'Delete all events' }).click(); + await page.getByRole('button', { name: 'Edit mode' }).click(); + await page.getByRole('button', { name: 'Clear rundown' }).click(); await page.getByRole('button', { name: 'Create Event' }).click(); await page.getByTestId('time-input-timeStart').fill('1m'); @@ -13,36 +12,40 @@ test('smoke test operator', async ({ page }) => { await page.getByTestId('time-input-duration').fill('1m'); await page.getByTestId('time-input-duration').press('Enter'); - await page.getByTestId('quick-add-event').click(); + await page.getByRole('button', { name: 'Event', exact: true }).nth(1).click(); await page.getByTestId('entry-2').getByTestId('lock__duration').click(); await page.getByTestId('entry-2').getByTestId('time-input-duration').fill('1m'); await page.getByTestId('entry-2').getByTestId('time-input-duration').press('Enter'); await page.getByTestId('entry-2').getByTestId('time-input-duration').press('Enter'); - await page.getByTestId('quick-add-event').click(); + await page.getByRole('button', { name: 'Event Alt E', exact: true }).click(); await page.getByTestId('entry-3').getByTestId('lock__duration').click(); await page.getByTestId('entry-3').getByTestId('time-input-duration').fill('1m'); await page.getByTestId('entry-3').getByTestId('time-input-duration').press('Enter'); - await page.getByRole('button', { name: 'Rundown menu' }).click(); - await page.getByRole('menuitem', { name: 'Add block at start' }).click(); - await page.getByTestId('quick-add-block').click(); + await page.getByRole('button', { name: 'Block', exact: true }).nth(0).click(); await page.getByRole('button', { name: 'Edit mode' }).click(); - await page.getByTestId('entry-1').getByRole('button', { name: 'Event options' }).first().click(); - await page.getByLabel('Title', { exact: true }).click(); - await page.getByLabel('Title', { exact: true }).fill('title 1'); - await page.getByLabel('Title', { exact: true }).press('Enter'); + await page.getByTestId('entry-1').click({ button: 'right' }); + await page.getByRole('menuitem', { name: 'Event after' }).click(); - await page.getByTestId('entry-2').getByRole('button', { name: 'Event options' }).first().click(); - await page.getByLabel('Title', { exact: true }).click(); - await page.getByLabel('Title', { exact: true }).fill('title 2'); - await page.getByLabel('Title', { exact: true }).press('Enter'); + await page.getByTestId('entry-1').getByTestId('block__title').click(); + await page.getByTestId('entry-1').getByTestId('block__title').fill('title 1'); + await page.getByTestId('entry-1').getByTestId('block__title').press('Enter'); - await page.getByTestId('entry-3').getByRole('button', { name: 'Event options' }).first().click(); - await page.getByLabel('Title', { exact: true }).click(); - await page.getByLabel('Title', { exact: true }).fill('title 3'); - await page.getByLabel('Title', { exact: true }).press('Enter'); + await page.getByTestId('entry-2').click({ button: 'right' }); + await page.getByRole('menuitem', { name: 'Event after' }).click(); + + await page.getByTestId('entry-2').getByTestId('block__title').click(); + await page.getByTestId('entry-2').getByTestId('block__title').fill('title 2'); + await page.getByTestId('entry-2').getByTestId('block__title').press('Enter'); + + await page.getByTestId('entry-3').click({ button: 'right' }); + await page.getByRole('menuitem', { name: 'Event after' }).click(); + + await page.getByTestId('entry-3').getByTestId('block__title').click(); + await page.getByTestId('entry-3').getByTestId('block__title').fill('title 3'); + await page.getByTestId('entry-3').getByTestId('block__title').press('Enter'); // start an event await page.getByTestId('panel-timer-control').getByRole('button', { name: 'Start' }).click(); diff --git a/e2e/tests/features/301-spreadsheet-import.spec.ts b/e2e/tests/features/301-spreadsheet-import.spec.ts index 5f56fc125..98ecb5607 100644 --- a/e2e/tests/features/301-spreadsheet-import.spec.ts +++ b/e2e/tests/features/301-spreadsheet-import.spec.ts @@ -4,8 +4,8 @@ const fileToUpload = 'e2e/tests/fixtures/test-sheet.xlsx'; test('sheet file upload', async ({ page }) => { await page.goto('http://localhost:4001/editor'); - await page.getByRole('button', { name: 'Rundown menu' }).click(); - await page.getByRole('menuitem', { name: 'Delete all events' }).click(); + await page.getByRole('button', { name: 'Edit mode' }).click(); + await page.getByRole('button', { name: 'Clear rundown' }).click(); await page.getByRole('button', { name: 'Toggle settings' }).click(); await page.getByRole('button', { name: 'Import spreadsheet' }).click(); From 01c2ef4c4decdb863f9ef730120c11c3370abfb7 Mon Sep 17 00:00:00 2001 From: Carlos Valente <34649812+cpvalente@users.noreply.github.com> Date: Tue, 2 Apr 2024 22:45:03 +0200 Subject: [PATCH 4/9] V3 feedback (#866) * refactor: rename settings panel * style: typo * style: fix blackout position * fix: offset time in time-to-end * refactor: operator is protected * style: tweaks to pincode * refactor: close params editor on submit * refactor: same navigation in all pages * refactor: use cached response --- .eslintrc | 1 + .../NavigationMenu.module.scss | 4 +- .../navigation-menu/NavigationMenu.tsx | 112 +++++++++++++++++- .../ProductionNavigationMenu.tsx | 79 +----------- .../navigation-menu/ViewNavigationMenu.tsx | 71 +---------- .../components/protect-route/PinPage.tsx | 17 ++- .../protect-route/ProtectRoute.module.scss | 32 ++--- .../ViewParamsEditor.module.scss | 12 +- .../view-params-editor/ViewParamsEditor.tsx | 16 +-- .../src/common/hooks-query/useCustomFields.ts | 2 +- .../src/common/hooks-query/useHttpSettings.ts | 3 +- apps/client/src/common/hooks-query/useInfo.ts | 2 +- .../src/common/hooks-query/useOscSettings.ts | 2 +- .../src/common/hooks-query/useProjectData.ts | 2 +- .../src/common/hooks-query/useProjectList.ts | 2 +- .../src/common/hooks-query/useRundown.ts | 2 +- .../src/common/hooks-query/useSettings.ts | 2 +- .../src/common/hooks-query/useUrlPresets.ts | 2 +- .../src/common/hooks-query/useViewSettings.ts | 4 +- .../panel/general-panel/GeneralPanel.tsx | 2 +- .../features/app-settings/settingsStore.ts | 3 +- .../control/message/MessageControl.tsx | 4 +- .../src/features/operator/OperatorExport.tsx | 5 +- .../src/features/viewers/timer/Timer.scss | 2 + .../src/services/__tests__/timerUtils.test.ts | 7 +- apps/server/src/services/timerUtils.ts | 4 +- e2e/tests/features/206-url-preset.spec.ts | 2 +- 27 files changed, 184 insertions(+), 212 deletions(-) diff --git a/.eslintrc b/.eslintrc index 94aadac2d..7e5b262e8 100644 --- a/.eslintrc +++ b/.eslintrc @@ -19,6 +19,7 @@ "rules": { "no-useless-concat": "warn", "prefer-template": "warn", + "no-throw-literal": "error", "no-console": [ "warn", { diff --git a/apps/client/src/common/components/navigation-menu/NavigationMenu.module.scss b/apps/client/src/common/components/navigation-menu/NavigationMenu.module.scss index 2a54aeab4..acb7e09b7 100644 --- a/apps/client/src/common/components/navigation-menu/NavigationMenu.module.scss +++ b/apps/client/src/common/components/navigation-menu/NavigationMenu.module.scss @@ -45,8 +45,8 @@ $button-size: 3rem; .link { @include action-link; - justify-content: space-between; padding: 0.75rem 1.5rem; + gap: 0.5rem; &:hover { background-color: $menu-hover-bg; @@ -69,7 +69,7 @@ $button-size: 3rem; } .linkIcon { - display: inline-block; + margin-left: auto; transform: rotate(45deg); } diff --git a/apps/client/src/common/components/navigation-menu/NavigationMenu.tsx b/apps/client/src/common/components/navigation-menu/NavigationMenu.tsx index 7a5e7aba9..2e2a4ee19 100644 --- a/apps/client/src/common/components/navigation-menu/NavigationMenu.tsx +++ b/apps/client/src/common/components/navigation-menu/NavigationMenu.tsx @@ -1,16 +1,43 @@ -import { memo, PropsWithChildren, useRef } from 'react'; +import { memo, useRef } from 'react'; import { createPortal } from 'react-dom'; -import { Drawer, DrawerBody, DrawerCloseButton, DrawerContent, DrawerHeader, DrawerOverlay } from '@chakra-ui/react'; +import { Link } from 'react-router-dom'; +import { + Drawer, + DrawerBody, + DrawerCloseButton, + DrawerContent, + DrawerHeader, + DrawerOverlay, + useDisclosure, +} from '@chakra-ui/react'; +import { useFullscreen } from '@mantine/hooks'; +import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp'; +import { IoContract } from '@react-icons/all-files/io5/IoContract'; +import { IoExpand } from '@react-icons/all-files/io5/IoExpand'; +import { IoLockClosedOutline } from '@react-icons/all-files/io5/IoLockClosedOutline'; +import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical'; +import { navigatorConstants } from '../../../viewerConfig'; import useClickOutside from '../../hooks/useClickOutside'; +import { useViewOptionsStore } from '../../stores/viewOptions'; +import { isKeyEnter } from '../../utils/keyEvent'; + +import RenameClientModal from './rename-client-modal/RenameClientModal'; + +import style from './NavigationMenu.module.scss'; interface NavigationMenuProps { isOpen: boolean; onClose: () => void; } -function NavigationMenu(props: PropsWithChildren) { - const { children, isOpen, onClose } = props; +function NavigationMenu(props: NavigationMenuProps) { + const { isOpen, onClose } = props; + + const { isOpen: isRenameOpen, onOpen: onRenameOpen, onClose: onRenameClose } = useDisclosure(); + + const { fullscreen, toggle } = useFullscreen(); + const { toggleMirror } = useViewOptionsStore(); const menuRef = useRef(null); @@ -18,6 +45,7 @@ function NavigationMenu(props: PropsWithChildren) { return createPortal( , diff --git a/apps/client/src/common/components/navigation-menu/ProductionNavigationMenu.tsx b/apps/client/src/common/components/navigation-menu/ProductionNavigationMenu.tsx index 60ad24587..2841ba069 100644 --- a/apps/client/src/common/components/navigation-menu/ProductionNavigationMenu.tsx +++ b/apps/client/src/common/components/navigation-menu/ProductionNavigationMenu.tsx @@ -1,19 +1,7 @@ import { memo } from 'react'; -import { Link, useLocation } from 'react-router-dom'; -import { useDisclosure } from '@chakra-ui/react'; -import { useFullscreen } from '@mantine/hooks'; -import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp'; -import { IoContract } from '@react-icons/all-files/io5/IoContract'; -import { IoExpand } from '@react-icons/all-files/io5/IoExpand'; -import { navigatorConstants } from '../../../viewerConfig'; -import { isKeyEnter } from '../../utils/keyEvent'; - -import RenameClientModal from './rename-client-modal/RenameClientModal'; import NavigationMenu from './NavigationMenu'; -import style from './NavigationMenu.module.scss'; - interface ProductionNavigationMenuProps { isMenuOpen: boolean; onMenuClose: () => void; @@ -21,73 +9,8 @@ interface ProductionNavigationMenuProps { function ProductionNavigationMenu(props: ProductionNavigationMenuProps) { const { isMenuOpen, onMenuClose } = props; - const location = useLocation(); - const { fullscreen, toggle } = useFullscreen(); - const { isOpen, onOpen, onClose } = useDisclosure(); - return ( - - -
-
{ - isKeyEnter(event) && toggle(); - }} - > - Toggle Fullscreen - {fullscreen ? : } -
-
{ - isKeyEnter(event) && onOpen(); - }} - > - Rename Client -
-
-
- - Editor - - - - Cuesheet - - - - Operator - - -
- {navigatorConstants.map((route) => ( - - {route.label} - - - ))} -
- ); + return ; } export default memo(ProductionNavigationMenu); diff --git a/apps/client/src/common/components/navigation-menu/ViewNavigationMenu.tsx b/apps/client/src/common/components/navigation-menu/ViewNavigationMenu.tsx index 10cc9fee1..3cb203ac4 100644 --- a/apps/client/src/common/components/navigation-menu/ViewNavigationMenu.tsx +++ b/apps/client/src/common/components/navigation-menu/ViewNavigationMenu.tsx @@ -1,28 +1,12 @@ import { memo, useCallback } from 'react'; -import { Link, useLocation, useSearchParams } from 'react-router-dom'; +import { useSearchParams } from 'react-router-dom'; import { useDisclosure } from '@chakra-ui/react'; -import { useFullscreen } from '@mantine/hooks'; -import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp'; -import { IoContract } from '@react-icons/all-files/io5/IoContract'; -import { IoExpand } from '@react-icons/all-files/io5/IoExpand'; -import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical'; -import { navigatorConstants } from '../../../viewerConfig'; -import { useViewOptionsStore } from '../../stores/viewOptions'; -import { isKeyEnter } from '../../utils/keyEvent'; - -import RenameClientModal from './rename-client-modal/RenameClientModal'; import FloatingNavigation from './FloatingNavigation'; import NavigationMenu from './NavigationMenu'; -import style from './NavigationMenu.module.scss'; - function ViewNavigationMenu() { - const location = useLocation(); - const { fullscreen, toggle } = useFullscreen(); - const { toggleMirror } = useViewOptionsStore(); const [searchParams, setSearchParams] = useSearchParams(); - const { isOpen: isRenameOpen, onOpen: onRenameOpen, onClose: onRenameClose } = useDisclosure(); const { isOpen: isMenuOpen, onOpen: onMenuOpen, onClose: onMenuClose } = useDisclosure(); const showEditFormDrawer = useCallback(() => { @@ -35,58 +19,7 @@ function ViewNavigationMenu() { return ( <> - - -
-
{ - isKeyEnter(event) && toggle(); - }} - > - Toggle Fullscreen - {fullscreen ? : } -
-
toggleMirror()} - onKeyDown={(event) => { - isKeyEnter(event) && toggleMirror(); - }} - > - Flip Screen - -
-
{ - isKeyEnter(event) && onRenameOpen(); - }} - > - Rename Client -
-
-
- {navigatorConstants.map((route) => ( - - {route.label} - - - ))} -
+ ); } diff --git a/apps/client/src/common/components/protect-route/PinPage.tsx b/apps/client/src/common/components/protect-route/PinPage.tsx index dfbd461e7..1648f23e4 100644 --- a/apps/client/src/common/components/protect-route/PinPage.tsx +++ b/apps/client/src/common/components/protect-route/PinPage.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useState } from 'react'; -import { HStack, IconButton, PinInput, PinInputField } from '@chakra-ui/react'; +import { IconButton, PinInput, PinInputField } from '@chakra-ui/react'; import { IoCheckmark } from '@react-icons/all-files/io5/IoCheckmark'; import style from './ProtectRoute.module.scss'; @@ -39,8 +39,8 @@ export default function PinPage(props: PinPageProps) { return (
- {`Ontime ${permission || ''}`} - + {`Ontime ${permission}`} +
- } onClick={validate} /> - + } + onClick={validate} + /> +
); } diff --git a/apps/client/src/common/components/protect-route/ProtectRoute.module.scss b/apps/client/src/common/components/protect-route/ProtectRoute.module.scss index f5aca2c70..7bceda037 100644 --- a/apps/client/src/common/components/protect-route/ProtectRoute.module.scss +++ b/apps/client/src/common/components/protect-route/ProtectRoute.module.scss @@ -1,27 +1,31 @@ .container { - display: grid; - place-content: center; - height: 100vh; - padding-bottom: 30vh; + display: flex; + flex-direction: column; + align-items: center; + padding-top: 25vh; - background: $bg-container-l1; color: $ontime-color; - font-family: $ontime-font-family; font-weight: 200; - text-align: center; - font-size: 3vw; + font-size: 3rem; } .pin, .pin__failed { - padding: 20px; + display: flex; + gap: 0.125em; + padding-block: 0.5em; input { - border-radius: 50%; + border-radius: 99px; + border-color: $gray-500; + + &:hover { + border-color: $blue-500; + } } button { - margin-left: 20px; + margin-left: 1em; } } @@ -33,9 +37,9 @@ @keyframes colourFade { from { - background: $action-blue; + background: $red-500; } to { - background: rgba($action-blue, 0); + background: rgba($red-500, 0); } -} +} \ No newline at end of file diff --git a/apps/client/src/common/components/view-params-editor/ViewParamsEditor.module.scss b/apps/client/src/common/components/view-params-editor/ViewParamsEditor.module.scss index 79ef8387f..53de3badc 100644 --- a/apps/client/src/common/components/view-params-editor/ViewParamsEditor.module.scss +++ b/apps/client/src/common/components/view-params-editor/ViewParamsEditor.module.scss @@ -1,14 +1,9 @@ .drawerFooter { display: flex; - justify-content: start; + justify-content: end; gap: $section-spacing; - button[type='reset'] { - padding: 0 2em; - margin-right: auto; - } - - button[type='submit'] { + button { padding: 0 2em; } } @@ -16,6 +11,9 @@ .label { font-size: $inner-section-text-size; color: $label-gray; + display: flex; + flex-direction: column; + gap: 0.25rem } .columnSection { diff --git a/apps/client/src/common/components/view-params-editor/ViewParamsEditor.tsx b/apps/client/src/common/components/view-params-editor/ViewParamsEditor.tsx index 055257030..b2bc55f26 100644 --- a/apps/client/src/common/components/view-params-editor/ViewParamsEditor.tsx +++ b/apps/client/src/common/components/view-params-editor/ViewParamsEditor.tsx @@ -52,15 +52,16 @@ export default function ViewParamsEditor({ paramFields }: EditFormDrawerProps) { } }, [searchParams, onOpen]); - const onCloseWithoutSaving = () => { - onClose(); - + const handleClose = () => { searchParams.delete('edit'); setSearchParams(searchParams); + + onClose(); }; const resetParams = () => { setSearchParams(); + onClose(); }; const onParamsFormSubmit = (formEvent: FormEvent) => { @@ -69,10 +70,12 @@ export default function ViewParamsEditor({ paramFields }: EditFormDrawerProps) { const newParamsObject = Object.fromEntries(new FormData(formEvent.currentTarget)); const newSearchParams = getURLSearchParamsFromObj(newParamsObject, paramFields); setSearchParams(newSearchParams); + + handleClose(); }; return ( - + @@ -96,10 +99,7 @@ export default function ViewParamsEditor({ paramFields }: EditFormDrawerProps) { -
+ - + ); } diff --git a/apps/client/src/features/viewers/timer/Timer.scss b/apps/client/src/features/viewers/timer/Timer.scss index 7eedbe2f1..0155374ab 100644 --- a/apps/client/src/features/viewers/timer/Timer.scss +++ b/apps/client/src/features/viewers/timer/Timer.scss @@ -32,6 +32,8 @@ .blackout { position: absolute; + top: 0; + left: 0; width: 100vw; height: 100vh; background-color: #000; diff --git a/apps/server/src/services/__tests__/timerUtils.test.ts b/apps/server/src/services/__tests__/timerUtils.test.ts index 6ec0f6b46..336ad47c4 100644 --- a/apps/server/src/services/__tests__/timerUtils.test.ts +++ b/apps/server/src/services/__tests__/timerUtils.test.ts @@ -1664,7 +1664,7 @@ describe('getRuntimeOffset()', () => { expectedEnd: 81600000, // 22:40:00 }, timer: { - addedTime: 0, + addedTime: -200000, current: -400000, duration: 3600000, elapsed: 4000000, @@ -1678,7 +1678,7 @@ describe('getRuntimeOffset()', () => { } as RuntimeState; const offset = getRuntimeOffset(state); - expect(offset).toBe(-400000); + expect(offset).toBe(400000); // <--- offset is always the overtime }); it('handles time-to-end started after the end time', () => { @@ -1732,7 +1732,8 @@ describe('getRuntimeOffset()', () => { const updateCurrent = getCurrent(state); state.timer.current = updateCurrent; const offset = getRuntimeOffset(state); - expect(offset).toBe(81000000 - 82000000); // <-- planned end - now + expect(millisToString(offset)).toBe('00:16:40'); + expect(offset).toBe(82000000 - 81000000); // <-- now - planned end }); }); diff --git a/apps/server/src/services/timerUtils.ts b/apps/server/src/services/timerUtils.ts index 71b796da8..114236b08 100644 --- a/apps/server/src/services/timerUtils.ts +++ b/apps/server/src/services/timerUtils.ts @@ -313,7 +313,7 @@ export function getRuntimeOffset(state: RuntimeState): MaybeNumber { return clock - timeStart; } - const overtime = Math.min(current, 0); + const overtime = Math.abs(Math.min(current, 0)); // in time-to-end, offset is overtime if (timerType === TimerType.TimeToEnd) { return overtime; @@ -322,7 +322,7 @@ export function getRuntimeOffset(state: RuntimeState): MaybeNumber { const startOffset = startedAt - timeStart; const pausedTime = state._timer.pausedAt === null ? 0 : clock - state._timer.pausedAt; - return startOffset + addedTime + pausedTime + Math.abs(overtime); + return startOffset + addedTime + pausedTime + overtime; } /** diff --git a/e2e/tests/features/206-url-preset.spec.ts b/e2e/tests/features/206-url-preset.spec.ts index 1c7451fce..427889b68 100644 --- a/e2e/tests/features/206-url-preset.spec.ts +++ b/e2e/tests/features/206-url-preset.spec.ts @@ -5,7 +5,7 @@ test('URL preset feature, it should redirect to given URL', async ({ page }) => // open settings await page.getByRole('button', { name: 'Toggle settings' }).click(); - await page.getByRole('button', { name: 'General' }).click(); + await page.getByRole('button', { name: 'App Settings' }).click(); // create preset await page.getByTestId('url-preset-form').scrollIntoViewIfNeeded(); From 8b8b3347fbd11710a6b41799e8cac45f3737d044 Mon Sep 17 00:00:00 2001 From: Alex Christoffer Rasmussen Date: Wed, 3 Apr 2024 14:18:04 +0200 Subject: [PATCH 5/9] fix: Sheet import error when user doesn't have correct permissions for the sheet (#865) * refactor: better throw error message * fix: await verify so wa catch the potential error * fix: catch and display error about varification * fix: don't block the Authenticate with a spinner * center spinner * propper boolean --- .../panel/sources-panel/GSheetSetup.tsx | 18 ++++++++++++------ .../sources-panel/SourcesPanel.module.scss | 1 + .../src/services/sheet-service/SheetService.ts | 18 +++++++++--------- 3 files changed, 22 insertions(+), 15 deletions(-) diff --git a/apps/client/src/features/app-settings/panel/sources-panel/GSheetSetup.tsx b/apps/client/src/features/app-settings/panel/sources-panel/GSheetSetup.tsx index 66efaef34..af9d6bddc 100644 --- a/apps/client/src/features/app-settings/panel/sources-panel/GSheetSetup.tsx +++ b/apps/client/src/features/app-settings/panel/sources-panel/GSheetSetup.tsx @@ -1,9 +1,10 @@ import { ChangeEvent, useEffect, useState } from 'react'; -import { Button, Input } from '@chakra-ui/react'; +import { Button, Input, Spinner } from '@chakra-ui/react'; import { IoCheckmark } from '@react-icons/all-files/io5/IoCheckmark'; import { IoShieldCheckmarkOutline } from '@react-icons/all-files/io5/IoShieldCheckmarkOutline'; import { getWorksheetNames } from '../../../../common/api/sheets'; +import { maybeAxiosError } from '../../../../common/api/utils'; import CopyTag from '../../../../common/components/copy-tag/CopyTag'; import { openLink } from '../../../../common/utils/linkUtils'; import * as Panel from '../PanelUtils'; @@ -29,7 +30,7 @@ export default function GSheetSetup(props: GSheetSetupProps) { const sheetId = useSheetStore((state) => state.sheetId); const setSheetId = useSheetStore((state) => state.setSheetId); const setWorksheets = useSheetStore((state) => state.setWorksheets); - + const patchStepData = useSheetStore((state) => state.patchStepData); const authenticationStatus = useSheetStore((state) => state.authenticationStatus); const setAuthenticationStatus = useSheetStore((state) => state.setAuthenticationStatus); @@ -91,8 +92,13 @@ export default function GSheetSetup(props: GSheetSetupProps) { setAuthenticationStatus(result.authenticated); if (result.authenticated !== 'pending') { if (result.authenticated == 'authenticated') { - const names = await getWorksheetNames(result.sheetId); - setWorksheets(names); + try { + const names = await getWorksheetNames(result.sheetId); + setWorksheets(names); + } catch (error) { + const message = maybeAxiosError(error); + patchStepData({ worksheet: { available: false, error: message } }); + } } setLoading(''); return; @@ -184,6 +190,7 @@ export default function GSheetSetup(props: GSheetSetupProps) { ) : (
+ {isAuthenticating && } {authKey ? authKey : 'Upload files to generate Auth Key'} @@ -192,8 +199,7 @@ export default function GSheetSetup(props: GSheetSetupProps) { size='sm' leftIcon={} onClick={handleAuthenticate} - isDisabled={!canAuthenticate || isLoading} - isLoading={loading === 'authenticate' || isAuthenticating} + isDisabled={!canAuthenticate} > Authenticate diff --git a/apps/client/src/features/app-settings/panel/sources-panel/SourcesPanel.module.scss b/apps/client/src/features/app-settings/panel/sources-panel/SourcesPanel.module.scss index e6d01046c..004b06bcb 100644 --- a/apps/client/src/features/app-settings/panel/sources-panel/SourcesPanel.module.scss +++ b/apps/client/src/features/app-settings/panel/sources-panel/SourcesPanel.module.scss @@ -26,6 +26,7 @@ .buttonRow { display: flex; gap: 1rem; + align-items: center; justify-content: end; } diff --git a/apps/server/src/services/sheet-service/SheetService.ts b/apps/server/src/services/sheet-service/SheetService.ts index d97a3b38e..5a796ee86 100644 --- a/apps/server/src/services/sheet-service/SheetService.ts +++ b/apps/server/src/services/sheet-service/SheetService.ts @@ -183,7 +183,7 @@ function verifyConnection( pollInterval = null; } - postAction(); + await postAction(); } catch (_error) { /** we do not handle failure */ } @@ -201,15 +201,15 @@ async function verifySheet( sheetId = currentSheetId, authClient = currentAuthClient, ): Promise<{ worksheetOptions: string[] }> { - const spreadsheets = await sheets({ version: 'v4', auth: authClient }).spreadsheets.get({ - spreadsheetId: sheetId, - includeGridData: false, - }); - - if (spreadsheets.status !== 200) { - throw new Error(spreadsheets.statusText); + try { + const spreadsheets = await sheets({ version: 'v4', auth: authClient }).spreadsheets.get({ + spreadsheetId: sheetId, + includeGridData: false, + }); + return { worksheetOptions: spreadsheets.data.sheets.map((i) => i.properties.title) }; + } catch (error) { + throw new Error(`Failed to verify sheet: ${error.message}`); } - return { worksheetOptions: spreadsheets.data.sheets.map((i) => i.properties.title) }; } export async function handleInitialConnection( From 9f207dd39ad0148071f3b39e02b8f53323b17d9c Mon Sep 17 00:00:00 2001 From: Ary Date: Wed, 3 Apr 2024 13:35:18 -0600 Subject: [PATCH 6/9] Fix: Removed `updatedAt` from project list (#853) --- .../app-settings/panel/project-panel/ProjectList.tsx | 4 +--- .../app-settings/panel/project-panel/ProjectListItem.tsx | 5 +---- apps/server/src/services/project-service/ProjectService.ts | 4 +--- .../project-service/__tests__/ProjectService.test.ts | 1 - .../types/src/api/ontime-controller/BackendResponse.type.ts | 1 - 5 files changed, 3 insertions(+), 12 deletions(-) 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 fb1eb2605..659f987d9 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 @@ -43,8 +43,7 @@ export default function ProjectList() { Project Name - Date Created - Date Modified + Last Used @@ -53,7 +52,6 @@ export default function ProjectList() { void; onSubmit: () => void; @@ -32,14 +31,13 @@ interface ProjectListItemProps { export default function ProjectListItem({ current, - createdAt, + updatedAt, editingFilename, editingMode, filename, onRefetch, onSubmit, onToggleEditMode, - updatedAt, }: ProjectListItemProps) { const [submitError, setSubmitError] = useState(null); @@ -102,7 +100,6 @@ export default function ProjectListItem({ ) : ( <> {filename} - {new Date(createdAt).toLocaleString()} {new Date(updatedAt).toLocaleString()} >} A promise that resolves to an array of ProjectFile objects, * each representing a file in the 'uploads' folder with its metadata. - * The metadata includes the filename, creation time (createdAt), - * and last modification time (updatedAt) of each file. + * The metadata includes the filename, creation or overwriting time (updatedAt) * * @throws {Error} Throws an error if there is an issue in reading the directory or fetching file statistics. */ @@ -83,7 +82,6 @@ export async function getProjectFiles(): Promise { projectFiles.push({ filename: removeFileExtension(file), - createdAt: stats.birthtime.toISOString(), updatedAt: stats.mtime.toISOString(), }); } diff --git a/apps/server/src/services/project-service/__tests__/ProjectService.test.ts b/apps/server/src/services/project-service/__tests__/ProjectService.test.ts index 877a2288f..dc0ab6fb9 100644 --- a/apps/server/src/services/project-service/__tests__/ProjectService.test.ts +++ b/apps/server/src/services/project-service/__tests__/ProjectService.test.ts @@ -23,7 +23,6 @@ describe('getProjectFiles test', () => { const expectedFiles = ['file1', 'file2', 'file3'].map((file) => ({ filename: file, - createdAt: new Date('2020-01-01').toISOString(), updatedAt: new Date('2021-01-01').toISOString(), })); diff --git a/packages/types/src/api/ontime-controller/BackendResponse.type.ts b/packages/types/src/api/ontime-controller/BackendResponse.type.ts index 699c34281..4cd3f09ce 100644 --- a/packages/types/src/api/ontime-controller/BackendResponse.type.ts +++ b/packages/types/src/api/ontime-controller/BackendResponse.type.ts @@ -15,7 +15,6 @@ export interface GetInfo { export type ProjectFile = { filename: string; - createdAt: string; updatedAt: string; }; From d432f1e3ff9d0ef76105eb135d9c0e46b767d8e2 Mon Sep 17 00:00:00 2001 From: Carlos Valente <34649812+cpvalente@users.noreply.github.com> Date: Fri, 5 Apr 2024 12:07:26 +0200 Subject: [PATCH 7/9] Typescript null (#869) * refactor: improve null checking * fix: return result of mutation --- apps/server/src/adapters/WebsocketAdapter.ts | 16 +- .../src/api-data/excel/excel.service.ts | 18 +- .../src/classes/simple-timer/SimpleTimer.ts | 1 + apps/server/src/services/RestoreService.ts | 2 +- .../project-service/ProjectService.ts | 2 +- .../rundown-service/RundownService.ts | 47 ++- .../services/rundown-service/rundownCache.ts | 34 +- apps/server/src/setup/loadDb.ts | 8 +- .../server/src/utils/__tests__/parser.test.ts | 8 +- apps/server/src/utils/parser.ts | 21 +- apps/server/src/utils/parserFunctions.ts | 322 +++++++++--------- apps/server/src/utils/parserUtils.ts | 10 +- 12 files changed, 260 insertions(+), 229 deletions(-) diff --git a/apps/server/src/adapters/WebsocketAdapter.ts b/apps/server/src/adapters/WebsocketAdapter.ts index 079f8aa54..f953af9a1 100644 --- a/apps/server/src/adapters/WebsocketAdapter.ts +++ b/apps/server/src/adapters/WebsocketAdapter.ts @@ -116,16 +116,14 @@ export class SocketServer implements IAdapter { // Protocol specific stuff handled above try { - const reply = dispatchFromAdapter( - type, - { - payload, - }, - 'ws', - ); + const reply = dispatchFromAdapter(type, { payload }, 'ws'); if (reply) { - const { payload } = reply; - ws.send(type, payload); + ws.send( + JSON.stringify({ + type: 'ontime-change', + payload: reply.payload, + }), + ); } } catch (error) { logger.error(LogOrigin.Rx, `WS IN: ${error}`); diff --git a/apps/server/src/api-data/excel/excel.service.ts b/apps/server/src/api-data/excel/excel.service.ts index 32930e31c..929ea6c90 100644 --- a/apps/server/src/api-data/excel/excel.service.ts +++ b/apps/server/src/api-data/excel/excel.service.ts @@ -3,10 +3,13 @@ * Google Sheets */ +import { CustomFields, OntimeRundown } from 'ontime-types'; +import { ImportMap } from 'ontime-utils'; + import { extname } from 'path'; import { existsSync } from 'fs'; -import { ImportMap } from 'ontime-utils'; import xlsx from 'node-xlsx'; + import { parseExcel } from '../../utils/parser.js'; import { parseCustomFields, parseRundown } from '../../utils/parserFunctions.js'; import { deleteFile } from '../../utils/parserUtils.js'; @@ -29,7 +32,7 @@ export function listWorksheets() { return excelData.map((value) => value.name); } -export function generateRundownPreview(options: ImportMap) { +export function generateRundownPreview(options: ImportMap): { rundown: OntimeRundown; customFields: CustomFields } { const data = excelData.find(({ name }) => name.toLowerCase() === options.worksheet.toLowerCase())?.data; if (!data) { @@ -39,15 +42,14 @@ export function generateRundownPreview(options: ImportMap) { const dataFromExcel = parseExcel(data, options); // we run the parsed data through an extra step to ensure the objects shape - const result = { rundown: [], customFields: {} }; - result.rundown = parseRundown(dataFromExcel); - if (result.rundown.length < 1) { + const rundown = parseRundown(dataFromExcel); + if (rundown.length === 0) { throw new Error(`Could not find data to import in the worksheet: ${options.worksheet}`); } - result.customFields = parseCustomFields(dataFromExcel); + const customFields = parseCustomFields(dataFromExcel); - //clear the data + // clear the data excelData = []; - return result; + return { rundown, customFields }; } diff --git a/apps/server/src/classes/simple-timer/SimpleTimer.ts b/apps/server/src/classes/simple-timer/SimpleTimer.ts index 31e31ca97..7465be16d 100644 --- a/apps/server/src/classes/simple-timer/SimpleTimer.ts +++ b/apps/server/src/classes/simple-timer/SimpleTimer.ts @@ -64,6 +64,7 @@ export class SimpleTimer { public update(timeNow: number): SimpleTimerState { if (this.state.playback === SimplePlayback.Start) { + // we know startedAt is not null since we are in play mode const elapsed = timeNow - this.startedAt; if (this.state.direction === SimpleDirection.CountDown) { this.state.current = this.state.duration - elapsed; diff --git a/apps/server/src/services/RestoreService.ts b/apps/server/src/services/RestoreService.ts index 46bfad9cd..01358eb22 100644 --- a/apps/server/src/services/RestoreService.ts +++ b/apps/server/src/services/RestoreService.ts @@ -64,7 +64,7 @@ export class RestoreService { private readonly filePath: MaybeString; private readonly file: JSONFile; private failedCreateAttempts: number; - private savedState: RestorePoint; + private savedState: RestorePoint | null; constructor(filePath: string) { this.filePath = filePath; diff --git a/apps/server/src/services/project-service/ProjectService.ts b/apps/server/src/services/project-service/ProjectService.ts index 55ab86b9d..191528330 100644 --- a/apps/server/src/services/project-service/ProjectService.ts +++ b/apps/server/src/services/project-service/ProjectService.ts @@ -75,7 +75,7 @@ export async function getProjectFiles(): Promise { const allFiles = await getFilesFromFolder(resolveProjectsDirectory); const filteredFiles = filterProjectFiles(allFiles); - const projectFiles = []; + const projectFiles: ProjectFile[] = []; for (const file of filteredFiles) { const filePath = join(resolveProjectsDirectory, file); const stats = await stat(filePath); diff --git a/apps/server/src/services/rundown-service/RundownService.ts b/apps/server/src/services/rundown-service/RundownService.ts index e3c11f2a5..fd245cbd6 100644 --- a/apps/server/src/services/rundown-service/RundownService.ts +++ b/apps/server/src/services/rundown-service/RundownService.ts @@ -22,20 +22,32 @@ import { runtimeService } from '../runtime-service/RuntimeService.js'; import * as cache from './rundownCache.js'; import { getPlayableEvents } from './rundownUtils.js'; -function generateEvent(eventData: Partial | Partial | Partial) { - // we discard any UI provided events and add our own +type PatchWithId = (Partial | Partial | Partial) & { id: string }; + +type CompleteEntry = T extends Partial + ? OntimeEvent + : T extends Partial + ? OntimeDelay + : T extends Partial + ? OntimeBlock + : never; + +function generateEvent | Partial | Partial>( + eventData: T, +): CompleteEntry { + // we discard any UI provided IDs and add our own const id = cache.getUniqueId(); if (isOntimeEvent(eventData)) { - return createEvent(eventData, getCueCandidate(cache.getPersistedRundown(), eventData?.after)) as OntimeEvent; + return createEvent(eventData, getCueCandidate(cache.getPersistedRundown(), eventData?.after)) as CompleteEntry; } if (isOntimeDelay(eventData)) { - return { ...delayDef, duration: eventData.duration ?? 0, id } as OntimeDelay; + return { ...delayDef, duration: eventData.duration ?? 0, id } as CompleteEntry; } if (isOntimeBlock(eventData)) { - return { ...blockDef, title: eventData?.title ?? '', id } as OntimeBlock; + return { ...blockDef, title: eventData?.title ?? '', id } as CompleteEntry; } throw new Error('Invalid event type'); @@ -46,9 +58,7 @@ function generateEvent(eventData: Partial | Partial | * @param {object} eventData * @return {OntimeRundownEntry} */ -export async function addEvent( - eventData: Partial | Partial | Partial, -): Promise { +export async function addEvent(eventData: PatchWithId & { after?: string }): Promise { // if the user didnt provide an index, we add the event to start let atIndex = 0; if (eventData?.after !== undefined) { @@ -62,15 +72,16 @@ export async function addEvent( // generate a fully formed event from the patch const eventToAdd = generateEvent(eventData); + // modify rundown const scopedMutation = cache.mutateCache(cache.add); - const { newEvent } = await scopedMutation({ atIndex, event: eventToAdd as OntimeRundownEntry }); + const { newEvent } = await scopedMutation({ atIndex, event: eventToAdd }); // notify runtime that rundown has changed updateRuntimeOnChange(); // notify timer and external services of change - notifyChanges({ timer: [newEvent.id], external: true }); + notifyChanges({ timer: [eventData.id], external: true }); return newEvent; } @@ -81,7 +92,11 @@ export async function addEvent( */ export async function deleteEvent(eventId: string) { const scopedMutation = cache.mutateCache(cache.remove); - await scopedMutation({ eventId }); + const { didMutate } = await scopedMutation({ eventId }); + + if (didMutate === false) { + return; + } // notify runtime that rundown has changed updateRuntimeOnChange(); @@ -108,14 +123,18 @@ export async function deleteAllEvents() { * Apply patch to an element in rundown * @param patch */ -export async function editEvent(patch: Partial | Partial | Partial) { +export async function editEvent(patch: PatchWithId) { if (isOntimeEvent(patch) && patch?.cue === '') { throw new Error('Cue value invalid'); } const scopedMutation = cache.mutateCache(cache.edit); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- we know patch has an id - const { newEvent } = await scopedMutation({ patch, eventId: patch.id! }); + const { newEvent, didMutate } = await scopedMutation({ patch, eventId: patch.id }); + + // short circuit if nothing changed + if (didMutate === false) { + return newEvent; + } // notify runtime that rundown has changed updateRuntimeOnChange(); diff --git a/apps/server/src/services/rundown-service/rundownCache.ts b/apps/server/src/services/rundown-service/rundownCache.ts index 254cc0d91..e2851b96d 100644 --- a/apps/server/src/services/rundown-service/rundownCache.ts +++ b/apps/server/src/services/rundown-service/rundownCache.ts @@ -83,7 +83,7 @@ export function generate( let accumulatedDelay = 0; let daySpan = 0; - let previousEnd: number; + let previousEnd: MaybeNumber = null; for (let i = 0; i < initialRundown.length; i++) { const currentEvent = initialRundown[i]; @@ -106,7 +106,7 @@ export function generate( lastEnd = updatedEvent.timeEnd; // check if we go over midnight, account for eventual gaps - const gapOverMidnight = previousEnd > updatedEvent.timeStart; + const gapOverMidnight = previousEnd !== null && previousEnd > updatedEvent.timeStart; const durationOverMidnight = updatedEvent.timeStart > updatedEvent.timeEnd; if (gapOverMidnight || durationOverMidnight) { daySpan++; @@ -135,7 +135,9 @@ export function generate( isStale = false; totalDelay = accumulatedDelay; - totalDuration = getTotalDuration(firstStart, lastEnd, daySpan); + if (lastEnd !== null && firstStart !== null) { + totalDuration = getTotalDuration(firstStart, lastEnd, daySpan); + } return { rundown, order, links, totalDelay, totalDuration, assignedCustomProperties: assignedCustomFields }; } @@ -210,6 +212,7 @@ type MutationParams = T & CommonParams; type MutatingReturn = { newRundown: OntimeRundown; newEvent?: OntimeRundownEntry; + didMutate: boolean; }; type MutatingFn = (params: MutationParams) => MutatingReturn; @@ -227,7 +230,7 @@ export function mutateCache(mutation: MutatingFn) { */ isStale = true; - const { newEvent, newRundown } = mutation({ ...params, persistedRundown }); + const { newEvent, newRundown, didMutate } = mutation({ ...params, persistedRundown }); revision = revision + 1; persistedRundown = newRundown; @@ -244,7 +247,7 @@ export function mutateCache(mutation: MutatingFn) { DataProvider.setRundown(persistedRundown); }); - return { newEvent }; + return { newEvent, newRundown, didMutate }; } return scopedMutation; @@ -256,7 +259,7 @@ export function add({ persistedRundown, atIndex, event }: AddArgs): Required; @@ -265,11 +268,11 @@ export function remove({ persistedRundown, eventId }: RemoveArgs): MutatingRetur const atIndex = persistedRundown.findIndex((event) => event.id === eventId); const newRundown = deleteAtIndex(atIndex, persistedRundown); - return { newRundown }; + return { newRundown, didMutate: atIndex !== -1 }; } -export function removeAll(): { newRundown: OntimeRundown } { - return { newRundown: [] }; +export function removeAll(): MutatingReturn { + return { newRundown: [], didMutate: true }; } /** @@ -304,7 +307,7 @@ export function edit({ persistedRundown, eventId, patch }: EditArgs): Required }>; @@ -339,7 +343,7 @@ export function batchEdit({ persistedRundown, eventIds, patch }: BatchEditArgs): newRundown.push(persistedRundown[i]); } } - return { newRundown }; + return { newRundown, didMutate: true }; } type ReorderArgs = MutationParams<{ eventId: string; from: number; to: number }>; @@ -357,14 +361,14 @@ export function reorder({ persistedRundown, eventId, from, to }: ReorderArgs): R event.revision += 1; } } - return { newRundown, newEvent: newRundown.at(from) }; + return { newRundown, newEvent: newRundown.at(from) as OntimeRundownEntry, didMutate: true }; } type ApplyDelayArgs = MutationParams<{ eventId: string }>; export function applyDelay({ persistedRundown, eventId }: ApplyDelayArgs): MutatingReturn { const newRundown = apply(eventId, persistedRundown); - return { newRundown }; + return { newRundown, didMutate: true }; } type SwapArgs = MutationParams<{ fromId: string; toId: string }>; @@ -388,7 +392,7 @@ export function swap({ persistedRundown, fromId, toId }: SwapArgs): MutatingRetu newRundown[indexB] = newB; (newRundown[indexB] as OntimeEvent).revision += 1; - return { newRundown }; + return { newRundown, didMutate: true }; } /** diff --git a/apps/server/src/setup/loadDb.ts b/apps/server/src/setup/loadDb.ts index 81112f15e..4876dbc9b 100644 --- a/apps/server/src/setup/loadDb.ts +++ b/apps/server/src/setup/loadDb.ts @@ -25,7 +25,13 @@ const populateDb = (directory: string, filename: string): string => { if (!existsSync(dbPath)) { try { const dbDirectory = resolveDbDirectory; - const newFileDirectory = join(dbDirectory, pathToStartDb.split('/').pop()); + const startDbName = pathToStartDb.split('/').pop(); + + if (!startDbName) { + throw new Error('Invalid path to start database'); + } + + const newFileDirectory = join(dbDirectory, startDbName); copyFileSync(pathToStartDb, newFileDirectory); dbPath = newFileDirectory; diff --git a/apps/server/src/utils/__tests__/parser.test.ts b/apps/server/src/utils/__tests__/parser.test.ts index 988b1a7b5..002d09c86 100644 --- a/apps/server/src/utils/__tests__/parser.test.ts +++ b/apps/server/src/utils/__tests__/parser.test.ts @@ -318,7 +318,6 @@ describe('test parser edge cases', () => { // @ts-expect-error -- we know this is wrong, testing imports outside domain const parseResponse = await parseJson(testData); - expect(console.log).toHaveBeenCalledWith('ERROR: unkown event type, skipping'); expect(parseResponse?.rundown.length).toBe(0); }); @@ -332,7 +331,7 @@ describe('test parser edge cases', () => { // @ts-expect-error -- we know this is wrong, testing imports outside domain await parseJson(testData); - expect(console.log).toHaveBeenCalledWith('ERROR: unknown app version, skipping'); + expect(console.log).toHaveBeenCalledWith('ERROR: unable to parse settings, missing app or version'); }); }); @@ -564,6 +563,7 @@ describe('test views import', () => { normalColor: '#ffffffcc', warningColor: '#FFAB33', dangerColor: '#ED3333', + freezeEnd: false, endMessage: '', overrideStyles: false, }; @@ -581,7 +581,7 @@ describe('test views import', () => { }, } as DatabaseModel; const parsed = parseViewSettings(testData); - expect(parsed).toStrictEqual({}); + expect(parsed).toStrictEqual(dbModel.viewSettings); }); }); @@ -591,7 +591,7 @@ describe('test import of v2 datamodel', () => { rundown: [ { type: SupportedEvent.Block, title: 'block-title', id: 'block-id' }, { type: SupportedEvent.Delay, duration: 0 }, - { type: SupportedEvent.Event, title: 'block-title', id: 'block-id' }, + { type: SupportedEvent.Event, title: 'event-title', id: 'event-id' }, ], project: { title: '', diff --git a/apps/server/src/utils/parser.ts b/apps/server/src/utils/parser.ts index 71963aa5e..c9317a23c 100644 --- a/apps/server/src/utils/parser.ts +++ b/apps/server/src/utils/parser.ts @@ -273,15 +273,26 @@ export const parseJson = async (jsonData: Partial): Promise): OntimeRundown => { - let newRundown: OntimeRundown = []; - if ('rundown' in data) { - console.log('Found rundown definition, importing...'); - const rundown = []; - try { - let eventIndex = 0; - const ids = []; - for (const event of data.rundown) { - // double check unique ids - if (ids.includes(event?.id)) { - console.log('ERROR: ID collision on import, skipping'); - continue; - } - - if (isOntimeEvent(event)) { - eventIndex += 1; - const parsedEvent = createEvent(event, eventIndex.toString()); - if (event != null) { - rundown.push(parsedEvent); - ids.push(parsedEvent.id); - } - } else if (isOntimeDelay(event)) { - rundown.push({ - ...delayDef, - duration: event.duration, - id: event.id || generateId(), - }); - } else if (isOntimeBlock(event)) { - rundown.push({ ...blockDef, title: event.title, id: event.id || generateId() }); - } else { - console.log('ERROR: unkown event type, skipping'); - } - } - } catch (error) { - console.log(`Error ${error}`); - } - // write to db - newRundown = rundown; - console.log(`Uploaded file with ${newRundown.length} entries`); + if (!data.rundown) { + return []; } - return newRundown; + + console.log('Found rundown, importing...'); + + const rundown: OntimeRundown = []; + let eventIndex = 0; + const ids: string[] = []; + + for (const event of data.rundown) { + if (ids.includes(event.id)) { + console.log('ERROR: ID collision on import, skipping'); + continue; + } + + const id = event.id || generateId(); + let newEvent: OntimeEvent | OntimeDelay | OntimeBlock | null; + + if (isOntimeEvent(event)) { + newEvent = createEvent(event, eventIndex.toString()); + // skip if event is invalid + if (newEvent == null) { + continue; + } + eventIndex += 1; + } else if (isOntimeDelay(event)) { + newEvent = { ...delayDef, duration: event.duration, id }; + } else if (isOntimeBlock(event)) { + newEvent = { ...blockDef, title: event.title, id }; + } else { + console.log('ERROR: unknown event type, skipping'); + continue; + } + + if (newEvent) { + rundown.push(newEvent); + ids.push(id); + } + } + + console.log(`Uploaded rundown with ${rundown.length} entries`); + return rundown; }; + /** * Parse event portion of an entry - * @param {object} data - data object - * @returns {object} - event object data */ export const parseProject = (data: Partial): ProjectData => { - let newProjectData: Partial = {}; - // we are adding this here to aid transition, should be removed once enough time has past that users have fully migrated - if ('project' in data) { - console.log('Found project data, importing...'); - const project = data.project; - - // filter known properties and write to db - newProjectData = { - ...dbModel.project, - title: project.title || dbModel.project.title, - description: project.description || dbModel.project.description, - publicUrl: project.publicUrl || dbModel.project.publicUrl, - publicInfo: project.publicInfo || dbModel.project.publicInfo, - backstageUrl: project.backstageUrl || dbModel.project.backstageUrl, - backstageInfo: project.backstageInfo || dbModel.project.backstageInfo, - }; + if (!data.project) { + return { ...dbModel.project }; } - return newProjectData as ProjectData; + + console.log('Found project data, importing...'); + + return { + title: data.project.title ?? dbModel.project.title, + description: data.project.description ?? dbModel.project.description, + publicUrl: data.project.publicUrl ?? dbModel.project.publicUrl, + publicInfo: data.project.publicInfo ?? dbModel.project.publicInfo, + backstageUrl: data.project.backstageUrl ?? dbModel.project.backstageUrl, + backstageInfo: data.project.backstageInfo ?? dbModel.project.backstageInfo, + }; }; /** * Parse settings portion of an entry - * @param {object} data - data object - * @returns {object} - event object data */ -export const parseSettings = (data): Settings => { - let newSettings: Partial = {}; - if ('settings' in data) { - console.log('Found settings definition, importing...'); - const s = data.settings; - - // skip if file definition is missing - if (s?.app !== 'ontime' || s?.version == null) { - console.log('ERROR: unknown app version, skipping'); - } else { - const settings = { - version: dbModel.settings.version, - serverPort: s.serverPort ?? dbModel.settings.serverPort, - editorKey: s.editorKey ?? null, - operatorKey: s.operatorKey ?? null, - timeFormat: s.timeFormat ?? '24', - language: s.language ?? 'en', - }; - - // write to db - newSettings = { - ...dbModel.settings, - ...settings, - }; - } +export const parseSettings = (data: Partial): Settings => { + if (!data.settings) { + return { ...dbModel.settings }; } - return newSettings as Settings; + + // skip if file definition is missing + if (data.settings?.app !== 'ontime' || data.settings?.version == null) { + throw new Error('ERROR: unable to parse settings, missing app or version'); + } + + console.log('Found settings, importing...'); + + return { + app: dbModel.settings.app, + version: dbModel.settings.version, + serverPort: data.settings.serverPort ?? dbModel.settings.serverPort, + editorKey: data.settings.editorKey ?? null, + operatorKey: data.settings.operatorKey ?? null, + timeFormat: data.settings.timeFormat ?? '24', + language: data.settings.language ?? 'en', + }; }; /** - * Parse settings portion of an entry - * @param {object} data - data object - * @returns {object} - event object data + * Parse view settings portion of an entry */ export const parseViewSettings = (data: Partial): ViewSettings => { - let newViews: Partial = {}; - if ('viewSettings' in data) { - console.log('Found view definition, importing...'); - const v = data.viewSettings; - - const viewSettings = { - overrideStyles: v.overrideStyles ?? dbModel.viewSettings.overrideStyles, - normalColor: v.normalColor ?? dbModel.viewSettings.normalColor, - warningColor: v.warningColor ?? dbModel.viewSettings.warningColor, - dangerColor: v.dangerColor ?? dbModel.viewSettings.dangerColor, - endMessage: v.endMessage ?? dbModel.viewSettings.endMessage, - }; - - newViews = { ...viewSettings }; + if (!data.viewSettings) { + return { ...dbModel.viewSettings }; } - return newViews as ViewSettings; + + console.log('Found view settings, importing...'); + + return { + dangerColor: data.viewSettings.dangerColor ?? dbModel.viewSettings.dangerColor, + endMessage: data.viewSettings.endMessage ?? dbModel.viewSettings.endMessage, + freezeEnd: data.viewSettings.freezeEnd ?? dbModel.viewSettings.freezeEnd, + normalColor: data.viewSettings.normalColor ?? dbModel.viewSettings.normalColor, + overrideStyles: data.viewSettings.overrideStyles ?? dbModel.viewSettings.overrideStyles, + warningColor: data.viewSettings.warningColor ?? dbModel.viewSettings.warningColor, + }; }; /** @@ -170,20 +156,20 @@ export function sanitiseOscSubscriptions(subscriptions?: OscSubscription[]): Osc /** * Parse osc portion of an entry */ -export const parseOsc = (data: { osc?: Partial }): OSCSettings => { - if ('osc' in data) { - console.log('Found OSC definition, importing...'); - - const loadedConfig = data.osc || {}; - return { - portIn: loadedConfig.portIn ?? dbModel.osc.portIn, - portOut: loadedConfig.portOut ?? dbModel.osc.portOut, - targetIP: loadedConfig.targetIP ?? dbModel.osc.targetIP, - enabledIn: loadedConfig.enabledIn ?? dbModel.osc.enabledIn, - enabledOut: loadedConfig.enabledOut ?? dbModel.osc.enabledOut, - subscriptions: sanitiseOscSubscriptions(loadedConfig.subscriptions), - }; +export const parseOsc = (data: Partial): OSCSettings => { + if (!data.osc) { + return { ...dbModel.osc }; } + console.log('Found OSC settings, importing...'); + + return { + portIn: data.osc.portIn ?? dbModel.osc.portIn, + portOut: data.osc.portOut ?? dbModel.osc.portOut, + targetIP: data.osc.targetIP ?? dbModel.osc.targetIP, + enabledIn: data.osc.enabledIn ?? dbModel.osc.enabledIn, + enabledOut: data.osc.enabledOut ?? dbModel.osc.enabledOut, + subscriptions: sanitiseOscSubscriptions(data.osc.subscriptions), + }; }; /** @@ -206,66 +192,70 @@ export function sanitiseHttpSubscriptions(subscriptions?: HttpSubscription[]): H /** * Parse Http portion of an entry - * @param {object} data - data object - * @param {boolean} enforce - whether to create a definition if one is missing - * @returns {object} - event object data */ -export const parseHttp = (data: { http?: Partial }): HttpSettings => { - if ('http' in data) { - console.log('Found HTTP definition, importing...'); - - // TODO: this can be improved by only merging known keys - const loadedConfig = data?.http || {}; - - return { - enabledOut: loadedConfig.enabledOut ?? dbModel.http.enabledOut, - subscriptions: sanitiseHttpSubscriptions(loadedConfig.subscriptions), - }; +export const parseHttp = (data: Partial): HttpSettings => { + if (!data.http) { + return { ...dbModel.http }; } + + console.log('Found HTTP settings, importing...'); + + return { + enabledOut: data.http.enabledOut ?? dbModel.http.enabledOut, + subscriptions: sanitiseHttpSubscriptions(data.http.subscriptions), + }; }; /** * Parse URL preset portion of an entry - * @param {object} data - data object - * @returns {object} - event object data */ export const parseUrlPresets = (data: Partial): URLPreset[] => { - const newPresets: URLPreset[] = []; - if ('urlPresets' in data) { - console.log('Found URL presets definition, importing...'); - try { - for (const preset of data.urlPresets) { - const newPreset = { - enabled: preset.enabled ?? false, - alias: preset.alias ?? '', - pathAndParams: preset.pathAndParams ?? '', - }; - newPresets.push(newPreset); - } - console.log(`Uploaded ${newPresets.length} preset(s)`); - } catch (error) { - console.log(`Error: ${error}`); - } + if (!data.urlPresets) { + return []; } + + console.log('Found URL presets, importing...'); + + const newPresets: URLPreset[] = []; + + for (const preset of data.urlPresets) { + const newPreset = { + enabled: preset.enabled ?? false, + alias: preset.alias ?? '', + pathAndParams: preset.pathAndParams ?? '', + }; + newPresets.push(newPreset); + } + + console.log(`Uploaded ${newPresets.length} preset(s)`); + return newPresets; }; /** * Parse customFields entry - * @param {object} data - data object - * @returns {object} - event object data */ export const parseCustomFields = (data: Partial): CustomFields => { - let newCustomFields: CustomFields = { ...dbModel.customFields }; - - if ('customFields' in data) { - console.log('Found Custom Fields definition, importing...'); - try { - //TODO: validate - newCustomFields = { ...dbModel.customFields, ...data.customFields }; - } catch (error) { - console.log(`Error: ${error}`); - } + if (typeof data.customFields !== 'object') { + return { ...dbModel.customFields }; } - return { ...newCustomFields }; + + console.log('Found Custom Fields, importing...'); + + const newCustomFields: CustomFields = {}; + + for (const fieldLabel in data.customFields) { + const field = data.customFields[fieldLabel]; + if (!field.label || !field.type || !field.colour) { + console.log('ERROR: missing required field, skipping'); + continue; + } + newCustomFields[field.label] = { + type: field.type, + colour: field.colour, + label: field.label, + }; + } + + return newCustomFields; }; diff --git a/apps/server/src/utils/parserUtils.ts b/apps/server/src/utils/parserUtils.ts index 8eb85a0d3..90a128083 100644 --- a/apps/server/src/utils/parserUtils.ts +++ b/apps/server/src/utils/parserUtils.ts @@ -15,10 +15,9 @@ export const makeString = (val: unknown, fallback = ''): string => { /** * @description Delete file from system - * @param {string} file - reference to file */ -export const deleteFile = async (file) => { - unlink(file, (error) => { +export const deleteFile = async (filePath: string) => { + unlink(filePath, (error) => { if (error) { console.error('Could not delete file:', error); } @@ -67,11 +66,12 @@ export function mergeObject(a: T, b: Partial): T { * @description Removes undefined * @param {object} obj */ -export const removeUndefined = (obj: object) => { +export const removeUndefined = >(obj: T): Partial => { return Object.keys(obj).reduce((patched, key) => { if (typeof obj[key] !== 'undefined') { + // @ts-expect-error -- not sure how to type this patched[key] = obj[key]; } return patched; - }, {}); + }, {} as Partial); }; From 8a1474e8d6ac821629538bba17cc891ca500ea1c Mon Sep 17 00:00:00 2001 From: Alex Christoffer Rasmussen Date: Fri, 5 Apr 2024 23:06:40 +0200 Subject: [PATCH 8/9] Improve error handling in server (#812) * add functions to convert all errors to `ErrorResponse` type * replace all `{ message: error.toString() }` with `toErrorResponse(error)` * refactor: all controllers to to use getErrorMessage and add types to the Response * 404 for nonexistent api routes --- .../panel/sources-panel/SourcesPanel.tsx | 4 +- .../custom-fields/customFields.controller.ts | 20 ++++---- apps/server/src/api-data/db/db.controller.ts | 34 ++++++++----- .../src/api-data/http/http.controller.ts | 4 +- apps/server/src/api-data/index.ts | 5 ++ .../server/src/api-data/osc/osc.controller.ts | 4 +- .../api-data/project/project.controller.ts | 4 +- .../api-data/rundown/rundown.controller.ts | 25 ++++++--- .../api-data/settings/settings.controller.ts | 5 +- .../src/api-data/sheets/sheets.controller.ts | 51 ++++++++++++++----- .../url-presets/urlPresets.controller.ts | 4 +- .../view-settings/viewSettings.controller.ts | 4 +- .../src/api-integration/integration.router.ts | 10 ++-- packages/utils/index.ts | 2 +- packages/utils/src/generic/generic.ts | 2 +- 15 files changed, 120 insertions(+), 58 deletions(-) diff --git a/apps/client/src/features/app-settings/panel/sources-panel/SourcesPanel.tsx b/apps/client/src/features/app-settings/panel/sources-panel/SourcesPanel.tsx index 7bb896df4..fb804052b 100644 --- a/apps/client/src/features/app-settings/panel/sources-panel/SourcesPanel.tsx +++ b/apps/client/src/features/app-settings/panel/sources-panel/SourcesPanel.tsx @@ -2,7 +2,7 @@ import { ChangeEvent, useRef, useState } from 'react'; import { Button, Input } from '@chakra-ui/react'; import { IoCloudOutline } from '@react-icons/all-files/io5/IoCloudOutline'; import { IoDownloadOutline } from '@react-icons/all-files/io5/IoDownloadOutline'; -import { ImportMap, unpackError } from 'ontime-utils'; +import { getErrorMessage, ImportMap } from 'ontime-utils'; import { getWorksheetNames as getWorksheetNamesExcel, @@ -59,7 +59,7 @@ export default function SourcesPanel() { setImportFlow('excel'); setHasFile('done'); } catch (error) { - const errorMessage = unpackError(error); + const errorMessage = getErrorMessage(error); setError(`Error uploading file: ${errorMessage}`); setWorksheets(null); setHasFile('none'); diff --git a/apps/server/src/api-data/custom-fields/customFields.controller.ts b/apps/server/src/api-data/custom-fields/customFields.controller.ts index 42c428c08..71c55f5f8 100644 --- a/apps/server/src/api-data/custom-fields/customFields.controller.ts +++ b/apps/server/src/api-data/custom-fields/customFields.controller.ts @@ -1,7 +1,8 @@ -import { CustomField, CustomFields } from 'ontime-types'; +import { CustomField, CustomFields, ErrorResponse } from 'ontime-types'; import type { Request, Response } from 'express'; +import { getErrorMessage } from 'ontime-utils'; import { createCustomField, editCustomField, @@ -14,36 +15,37 @@ export async function getCustomFields(_req: Request, res: Response res.json(customFields); } -// Expects { label: