diff --git a/.github/workflows/build_v2.yml b/.github/workflows/build_v2.yml index 924be8d06..56279856c 100644 --- a/.github/workflows/build_v2.yml +++ b/.github/workflows/build_v2.yml @@ -35,7 +35,9 @@ jobs: - name: Release uses: softprops/action-gh-release@v1 with: - files: './apps/electron/dist/ontime-macOS.dmg' + files: | + ./apps/electron/dist/ontime-macOS-x64.dmg + ./apps/electron/dist/ontime-macOS-arm64.dmg env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 6cf8f2165..d4c12d822 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -1,38 +1,48 @@ # GETTING STARTED Ontime consists of 3 distinct parts -- __client__: A React app for Ontime's UI and web clients + +- __client__: A React app for Ontime's UI and web clients - __electron__: An electron app which facilitates the cross-platform distribution of Ontime - __server__: A node application which handles the domains services and integrations -The steps below will assume you have locally installed the necessary dependencies. +The steps below will assume you have locally installed the necessary dependencies. Other dependencies will be installed as part of the setup + - __node__ (>=16.16) - __pnpm__ (>=7) - __docker__ (only necessary to run and build docker images) ## LOCAL DEVELOPMENT -The electron app is only necessary to distribute an installable version of the app and is not required for local development. +The electron app is only necessary to distribute an installable version of the app and is not required for local +development. Locally, we would need to run both the React client and the node.js server in development mode From the project root, run the following commands + - __Install the project dependencies__ by running `pnpm i` - __Run dev mode__ by running `turbo dev` ### Debugging backend + To debug backend code in Node.js: + - Open two separate terminals and navigate to the `apps/client` and `apps/server` directories. -- In each terminal, run the command `pnpm dev` to start the development servers for both the client and server applications. -- If you need to set breakpoints and inspect the code execution, enable Node.js inspect mode by running `pnpm dev:inspect`. +- In each terminal, run the command `pnpm dev` to start the development servers for both the client and server + applications. +- If you need to set breakpoints and inspect the code execution, enable Node.js inspect mode by + running `pnpm dev:inspect`. ## TESTING -Generally we have 2 types of tests. +Generally we have 2 types of tests. + - Unit tests for functions that contain business logic - End-to-end tests for core features ### Unit tests + Unit tests are contained in mostly all the apps and packages (client, server and utils) You can run unit tests by running turbo `turbo test:pipeline` from the project root. @@ -41,12 +51,20 @@ This will run all tests and close test runner. Alternatively you can navigate to an app or project and run `pnpm test` to run those tests in watch mode ### E2E tests -E2E tests are in a separate package. On running, [playwright](https://playwright.dev/) will spin up an instance of the webserver to test against + +E2E tests are in a separate package. On running, [playwright](https://playwright.dev/) will spin up an instance of the +webserver to test against These tests also run against a separate version of the DB (test-db) You can run playwright tests from project root with `pnpm e2e` -When writing tests, it can be handy to run playwright in interactive mode with `pnpm e2e:i`. You would need to manually start the webserver with `pnpm dev:server` +When writing tests, it can be handy to run playwright in interactive mode with `pnpm e2e:i`. You would need to manually +start the webserver with `pnpm dev:server` + +Some other useful commands + +- `pnpm e2e --ui` open playwright UI +- `pnpm e2e --headed` run tests with a visible browser window ## CREATE AN INSTALLABLE FILE (Windows | MacOS | Linux) @@ -54,6 +72,7 @@ Ontime uses Electron to distribute the application. You can generate a distribution for your OS by running the following steps. From the project root, run the following commands + - __Install the project dependencies__ by running `pnpm i` - __Build the UI and server__ by running `turbo build:local` - __Create the package__ by running `turbo dist-win`, `turbo dist-mac` or `turbo dist-linux` @@ -66,10 +85,12 @@ Ontime provides a docker-compose file to aid with building and running docker im While it should allow for a generic setup, it might need to be modified to fit your infrastructure. From the project root, run the following commands + - __Install the project dependencies__ by running `pnpm i` - __Build docker image from__ by running `docker build -t getontime/ontime` - __Run docker image from compose__ by running `docker-compose up -d` Other useful commands + - __List running processes__ by running `docker ps` - __Kill running process__ by running `docker kill ` diff --git a/apps/client/package.json b/apps/client/package.json index d71fd4ab9..5989b0685 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -1,6 +1,6 @@ { "name": "ontime-ui", - "version": "2.0.9", + "version": "2.7.0", "private": true, "dependencies": { "@chakra-ui/react": "^2.7.0", diff --git a/apps/client/src/App.tsx b/apps/client/src/App.tsx index 1cb17bfb9..7cedda359 100644 --- a/apps/client/src/App.tsx +++ b/apps/client/src/App.tsx @@ -4,9 +4,9 @@ import { ChakraProvider } from '@chakra-ui/react'; import { QueryClientProvider } from '@tanstack/react-query'; import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; +import { ContextMenu } from './common/components/context-menu/ContextMenu'; import ErrorBoundary from './common/components/error-boundary/ErrorBoundary'; import { AppContextProvider } from './common/context/AppContext'; -import { ContextMenuProvider } from './common/context/ContextMenuContext'; import useElectronEvent from './common/hooks/useElectronEvent'; import { ontimeQueryClient } from './common/queryClient'; import { socketClientName } from './common/stores/connectionName'; @@ -52,18 +52,18 @@ function App() { - - -
- - + +
+ + + - - - -
-
- + +
+
+ +
+
diff --git a/apps/client/src/AppRouter.tsx b/apps/client/src/AppRouter.tsx index 694989cc2..4c6f5726d 100644 --- a/apps/client/src/AppRouter.tsx +++ b/apps/client/src/AppRouter.tsx @@ -1,7 +1,7 @@ -import { lazy, Suspense, useEffect } from 'react'; -import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom'; +import { lazy, Suspense } from 'react'; +import { Navigate, Route, Routes } from 'react-router-dom'; -import useAliases from './common/hooks-query/useAliases'; +import withAlias from './features/AliasWrapper'; import withData from './features/viewers/ViewWrapper'; const Editor = lazy(() => import('./features/editors/ProtectedEditor')); @@ -18,14 +18,14 @@ const Public = lazy(() => import('./features/viewers/public/Public')); const Lower = lazy(() => import('./features/viewers/lower-thirds/LowerWrapper')); const StudioClock = lazy(() => import('./features/viewers/studio/StudioClock')); -const STimer = withData(TimerView); -const SMinimalTimer = withData(MinimalTimerView); -const SClock = withData(ClockView); -const SCountdown = withData(Countdown); -const SBackstage = withData(Backstage); -const SPublic = withData(Public); -const SLowerThird = withData(Lower); -const SStudio = withData(StudioClock); +const STimer = withAlias(withData(TimerView)); +const SMinimalTimer = withAlias(withData(MinimalTimerView)); +const SClock = withAlias(withData(ClockView)); +const SCountdown = withAlias(withData(Countdown)); +const SBackstage = withAlias(withData(Backstage)); +const SPublic = withAlias(withData(Public)); +const SLowerThird = withAlias(withData(Lower)); +const SStudio = withAlias(withData(StudioClock)); const EditorFeatureWrapper = lazy(() => import('./features/EditorFeatureWrapper')); const RundownPanel = lazy(() => import('./features/rundown/RundownExport')); @@ -34,22 +34,6 @@ const MessageControl = lazy(() => import('./features/control/message/MessageCont const Info = lazy(() => import('./features/info/InfoExport')); export default function AppRouter() { - const { data } = useAliases(); - const location = useLocation(); - const navigate = useNavigate(); - - // navigate if is alias route - useEffect(() => { - if (!data) return; - - for (const d of data) { - if (`/${d.alias}` === location.pathname && d.enabled) { - navigate(`/${d.pathAndParams}`); - break; - } - } - }, [data, location, navigate]); - return ( diff --git a/apps/client/src/common/api/apiConstants.ts b/apps/client/src/common/api/apiConstants.ts index 6e86b7cfb..935a102f1 100644 --- a/apps/client/src/common/api/apiConstants.ts +++ b/apps/client/src/common/api/apiConstants.ts @@ -1,5 +1,3 @@ -export const STATIC_PORT = 4001; - // REST stuff export const EVENT_DATA = ['eventdata']; export const ALIASES = ['aliases']; @@ -15,8 +13,9 @@ export const RUNTIME = ['runtimeStore']; const location = window.location; const socketProtocol = location.protocol === 'https:' ? 'wss' : 'ws'; +const STATIC_PORT = 4001; export const serverPort = import.meta.env.DEV ? STATIC_PORT : location.port; -export const serverURL = import.meta.env.DEV ? `http://${location.hostname}:${serverPort}` : location.origin; +export const serverURL = `${location.protocol}//${location.hostname}:${serverPort}`; export const websocketUrl = `${socketProtocol}://${location.hostname}:${serverPort}/ws`; export const eventURL = `${serverURL}/eventdata`; diff --git a/apps/client/src/common/api/apiUtils.ts b/apps/client/src/common/api/apiUtils.ts index 613df45cc..1c15707fc 100644 --- a/apps/client/src/common/api/apiUtils.ts +++ b/apps/client/src/common/api/apiUtils.ts @@ -6,9 +6,14 @@ import { addLog } from '../stores/logger'; import { nowInMillis } from '../utils/time'; export function logAxiosError(prepend: string, error: unknown) { - const message = axios.isAxiosError(error) - ? `${prepend}, ${(error as AxiosError).response?.statusText}: ${(error as AxiosError).response?.data}` - : `${prepend}: ${error}`; + let message; + if (axios.isAxiosError(error)) { + const statusText = (error as AxiosError).response?.statusText ?? ''; + const data = (error as AxiosError).response?.data ?? ''; + message = `${prepend} ${statusText}: ${data}`; + } else { + message = `${prepend}: ${error}`; + } addLog({ id: generateId(), diff --git a/apps/client/src/common/api/eventsApi.ts b/apps/client/src/common/api/eventsApi.ts index c6ad73f67..bb574e340 100644 --- a/apps/client/src/common/api/eventsApi.ts +++ b/apps/client/src/common/api/eventsApi.ts @@ -50,6 +50,19 @@ export async function requestApplyDelay(eventId: string) { return axios.patch(`${rundownURL}/applydelay/${eventId}`); } +export type SwapEntry = { + from: string; + to: string; +}; + +/** + * @description HTTP request to swap two events + * @return {Promise} + */ +export async function requestEventSwap(data: SwapEntry) { + return axios.patch(`${rundownURL}/swap`, data); +} + /** * @description HTTP request to delete given event * @return {Promise} diff --git a/apps/client/src/common/api/ontimeApi.ts b/apps/client/src/common/api/ontimeApi.ts index c45e63cd1..6fc503259 100644 --- a/apps/client/src/common/api/ontimeApi.ts +++ b/apps/client/src/common/api/ontimeApi.ts @@ -1,13 +1,5 @@ import axios from 'axios'; -import { - Alias, - EventData, - OSCSettings, - OscSubscription, - Settings, - UserFields, - ViewSettings, -} from 'ontime-types'; +import { Alias, EventData, OSCSettings, OscSubscription, Settings, UserFields, ViewSettings } from 'ontime-types'; import { apiRepoLatest } from '../../externals'; import { InfoType } from '../models/Info'; diff --git a/apps/client/src/common/context/ContextMenuContext.module.scss b/apps/client/src/common/components/context-menu/ContextMenu.module.scss similarity index 100% rename from apps/client/src/common/context/ContextMenuContext.module.scss rename to apps/client/src/common/components/context-menu/ContextMenu.module.scss diff --git a/apps/client/src/common/components/context-menu/ContextMenu.tsx b/apps/client/src/common/components/context-menu/ContextMenu.tsx new file mode 100644 index 000000000..68d1932df --- /dev/null +++ b/apps/client/src/common/components/context-menu/ContextMenu.tsx @@ -0,0 +1,84 @@ +// logic (with some modifications) culled from: +// https://github.com/lukasbach/chakra-ui-contextmenu/blob/main/src/ContextMenu.tsx + +import { Fragment, ReactElement } from 'react'; +import { Menu, MenuButton, MenuDivider, MenuItem, MenuList } from '@chakra-ui/react'; +import { IconType } from '@react-icons/all-files'; +import { create } from 'zustand'; + +import style from './ContextMenu.module.scss'; + +type ContextMenuCoords = { + x: number; + y: number; +}; + +export type Option = { + label: string; + icon: IconType; + onClick: () => void; + withDivider?: boolean; + isDisabled?: boolean; +}; + +type ContextMenuStore = { + coords: ContextMenuCoords; + options: Option[]; + isOpen: boolean; + setContextMenu: (coords: ContextMenuCoords, options: Option[]) => void; + setIsOpen: (newIsOpen: boolean) => void; +}; + +export const useContextMenuStore = create((set) => ({ + coords: { x: 0, y: 0 }, + options: [], + isOpen: false, + setContextMenu: (coords, options) => set(() => ({ coords, options, isOpen: true })), + setIsOpen: (newIsOpen) => set(() => ({ isOpen: newIsOpen })), +})); + +interface ContextMenuProps { + // ReactElement type required due to early `return` (line 51) returning {children} + children: ReactElement; +} + +export const ContextMenu = ({ children }: ContextMenuProps) => { + const { coords, options, isOpen, setIsOpen } = useContextMenuStore(); + + const onClose = () => { + return setIsOpen(false); + }; + + if (!isOpen) { + return children; + } + + return ( + <> + {children} +
+ + + + {options.map(({ label, icon: Icon, onClick, withDivider, isDisabled }, i) => ( + + {withDivider && } + } onClick={onClick} isDisabled={isDisabled}> + {label} + + + ))} + + + + ); +}; diff --git a/apps/client/src/common/components/input/colour-input/SwatchSelect.tsx b/apps/client/src/common/components/input/colour-input/SwatchSelect.tsx index 84c6f9a57..bbafefec5 100644 --- a/apps/client/src/common/components/input/colour-input/SwatchSelect.tsx +++ b/apps/client/src/common/components/input/colour-input/SwatchSelect.tsx @@ -1,6 +1,6 @@ import { useCallback } from 'react'; -import { TitleActions } from '../../../../features/event-editor/composite/EventEditorTitles'; +import { TitleActions } from '../../../../features/event-editor/composite/EventEditorDataLeft'; import Swatch from './Swatch'; diff --git a/apps/client/src/common/components/input/time-input/TimeInput.tsx b/apps/client/src/common/components/input/time-input/TimeInput.tsx index 4f50659d7..04fdf5b31 100644 --- a/apps/client/src/common/components/input/time-input/TimeInput.tsx +++ b/apps/client/src/common/components/input/time-input/TimeInput.tsx @@ -37,7 +37,17 @@ function ButtonTooltip(name: TimeEntryField, warning?: string) { } export default function TimeInput(props: TimeInputProps) { - const { id, name, submitHandler, time = 0, delay = 0, placeholder, validationHandler, previousEnd = 0, warning } = props; + const { + id, + name, + submitHandler, + time = 0, + delay = 0, + placeholder, + validationHandler, + previousEnd = 0, + warning, + } = props; const { emitError } = useEmitLog(); const inputRef = useRef(null); const [value, setValue] = useState(''); @@ -191,7 +201,7 @@ export default function TimeInput(props: TimeInputProps) { void; -}; - -export const ContextMenuContext = createContext(null); - -export type Option = { - label: string; - icon: IconType; - onClick: () => void; -}; - -interface ContextMenuProviderProps { - children: ReactNode; -} - -export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => { - const [isOpen, setIsOpen] = useState(false); - - const [coords, setCoords] = useState({ x: 0, y: 0 }); - const [options, setOptions] = useState([]); - - const onClose = () => { - return setIsOpen(false); - }; - - const createContextMenu = (options: Option[], menuCoords: ContextMenuCoords) => { - setCoords(menuCoords); - setOptions(options); - setIsOpen(true); - }; - - return ( - - {children} - {isOpen && ( - <> -
- - - - {options.map(({ label, icon: Icon, onClick }, i) => ( - } onClick={onClick}> - {label} - - ))} - - - - )} - - ); -}; diff --git a/apps/client/src/common/hooks/useContextMenu.tsx b/apps/client/src/common/hooks/useContextMenu.tsx index d21f574f6..d98a4fba4 100644 --- a/apps/client/src/common/hooks/useContextMenu.tsx +++ b/apps/client/src/common/hooks/useContextMenu.tsx @@ -1,22 +1,16 @@ -import { MouseEvent, useContext } from 'react'; +import { MouseEvent } from 'react'; -import { ContextMenuContext, Option } from '../context/ContextMenuContext'; +import { Option, useContextMenuStore } from '../components/context-menu/ContextMenu'; export const useContextMenu = (options: Option[]) => { - const contextMenuContext = useContext(ContextMenuContext); - - if (contextMenuContext === null) { - throw new Error('useContextMenu should be wrapped by ContextMenuProvider'); - } - - const { createContextMenu } = contextMenuContext; + const { setContextMenu } = useContextMenuStore(); const localCreateContextMenu = (contextMenuEvent: MouseEvent) => { // prevent browser default context menu from showing up contextMenuEvent.preventDefault(); const { pageX, pageY } = contextMenuEvent; - return createContextMenu(options, { x: pageX, y: pageY }); + return setContextMenu({ x: pageX, y: pageY }, options); }; return [localCreateContextMenu]; diff --git a/apps/client/src/common/hooks/useEventAction.ts b/apps/client/src/common/hooks/useEventAction.ts index 4d3085774..1cb8af577 100644 --- a/apps/client/src/common/hooks/useEventAction.ts +++ b/apps/client/src/common/hooks/useEventAction.ts @@ -1,6 +1,7 @@ import { useCallback } from 'react'; import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { OntimeRundown, OntimeRundownEntry, SupportedEvent } from 'ontime-types'; +import { isOntimeEvent, OntimeRundown, OntimeRundownEntry } from 'ontime-types'; +import { getCueCandidate, swapOntimeEvents } from 'ontime-utils'; import { RUNDOWN_TABLE, RUNDOWN_TABLE_KEY } from '../api/apiConstants'; import { logAxiosError } from '../api/apiUtils'; @@ -9,18 +10,20 @@ import { requestApplyDelay, requestDelete, requestDeleteAll, + requestEventSwap, requestPostEvent, requestPutEvent, requestReorderEvent, + SwapEntry, } from '../api/eventsApi'; -import { useLocalEvent } from '../stores/localEvent'; +import { useEditorSettings } from '../stores/editorSettings'; /** * @description Set of utilities for events */ export const useEventAction = () => { const queryClient = useQueryClient(); - const eventSettings = useLocalEvent((state) => state.eventSettings); + const eventSettings = useEditorSettings((state) => state.eventSettings); const defaultPublic = eventSettings.defaultPublic; const startTimeIsLastEnd = eventSettings.startTimeIsLastEnd; @@ -28,9 +31,10 @@ export const useEventAction = () => { * Calls mutation to add new event * @private */ - const _addEventMutation = useMutation(requestPostEvent, { + const _addEventMutation = useMutation({ // Mutation finished, failed or successful // Fetch anyway, just to be sure + mutationFn: requestPostEvent, onSettled: () => { queryClient.invalidateQueries(RUNDOWN_TABLE); }, @@ -55,7 +59,7 @@ export const useEventAction = () => { const newEvent: Partial = { ...event }; // ************* CHECK OPTIONS specific to events - if (newEvent.type === SupportedEvent.Event) { + if (isOntimeEvent(newEvent)) { const applicationOptions = { defaultPublic: options?.defaultPublic ?? defaultPublic, startTimeIsLastEnd: options?.startTimeIsLastEnd ?? startTimeIsLastEnd, @@ -63,16 +67,20 @@ export const useEventAction = () => { after: options?.after, }; + if (newEvent?.cue === undefined) { + newEvent.cue = getCueCandidate(queryClient.getQueryData(RUNDOWN_TABLE) || [], options?.after); + } + // hard coding duration value to be as expected for now // this until timeOptions gets implemented - if (typeof newEvent?.timeStart !== 'undefined' && typeof newEvent.timeEnd !== 'undefined') { + if (newEvent?.timeStart !== undefined && newEvent.timeEnd !== undefined) { newEvent.duration = Math.max(0, newEvent?.timeEnd - newEvent?.timeStart) || 0; } if (applicationOptions.startTimeIsLastEnd && applicationOptions?.lastEventId) { const rundown = queryClient.getQueryData(RUNDOWN_TABLE) as OntimeRundown; const previousEvent = rundown.find((event) => event.id === applicationOptions.lastEventId); - if (typeof previousEvent !== 'undefined' && previousEvent.type === 'event') { + if (previousEvent !== undefined && previousEvent.type === 'event') { newEvent.timeStart = previousEvent.timeEnd; newEvent.timeEnd = previousEvent.timeEnd; } @@ -102,7 +110,8 @@ export const useEventAction = () => { * Calls mutation to update existing event * @private */ - const _updateEventMutation = useMutation(requestPutEvent, { + const _updateEventMutation = useMutation({ + mutationFn: requestPutEvent, // we optimistically update here onMutate: async (newEvent) => { // cancel ongoing queries @@ -117,7 +126,6 @@ export const useEventAction = () => { // Return a context with the previous and new events return { previousEvent, newEvent }; }, - // Mutation fails, rollback undoes optimist update onError: (_error, _newEvent, context) => { queryClient.setQueryData([RUNDOWN_TABLE_KEY, context?.newEvent.id], context?.previousEvent); @@ -148,7 +156,8 @@ export const useEventAction = () => { * Calls mutation to delete an event * @private */ - const _deleteEventMutation = useMutation(requestDelete, { + const _deleteEventMutation = useMutation({ + mutationFn: requestDelete, // we optimistically update here onMutate: async (eventId) => { // cancel ongoing queries @@ -196,7 +205,8 @@ export const useEventAction = () => { * Calls mutation to delete all events * @private */ - const _deleteAllEventsMutation = useMutation(requestDeleteAll, { + const _deleteAllEventsMutation = useMutation({ + mutationFn: requestDeleteAll, // we optimistically update here onMutate: async () => { // cancel ongoing queries @@ -239,7 +249,8 @@ export const useEventAction = () => { * Calls mutation to apply a delay * @private */ - const _applyDelayMutation = useMutation(requestApplyDelay, { + const _applyDelayMutation = useMutation({ + mutationFn: requestApplyDelay, // Mutation finished, failed or successful onSettled: () => { queryClient.invalidateQueries(RUNDOWN_TABLE); @@ -265,7 +276,8 @@ export const useEventAction = () => { * Calls mutation to reorder an event * @private */ - const _reorderEventMutation = useMutation(requestReorderEvent, { + const _reorderEventMutation = useMutation({ + mutationFn: requestReorderEvent, // we optimistically update here onMutate: async (data) => { // cancel ongoing queries @@ -316,5 +328,67 @@ export const useEventAction = () => { [_reorderEventMutation], ); - return { addEvent, updateEvent, deleteEvent, deleteAllEvents, applyDelay, reorderEvent }; + /** + * Calls mutation to swap events + * @private + */ + const _swapEvents = useMutation({ + mutationFn: requestEventSwap, + // we optimistically update here + onMutate: async ({ from, to }) => { + // cancel ongoing queries + await queryClient.cancelQueries(RUNDOWN_TABLE, { exact: true }); + + // Snapshot the previous value + const rundown = queryClient.getQueryData(RUNDOWN_TABLE) as OntimeRundown; + + const fromEventIndex = rundown.findIndex((event) => event.id === from); + const toEventIndex = rundown.findIndex((event) => event.id === to); + + const previousEvents = swapOntimeEvents(rundown, fromEventIndex, toEventIndex); + + // optimistically update object + queryClient.setQueryData(RUNDOWN_TABLE, previousEvents); + + // Return a context with the previous events + return { previousEvents }; + }, + + // Mutation fails, rollback undoes optimist update + onError: (_error, _eventId, context) => { + queryClient.setQueryData(RUNDOWN_TABLE, context?.previousEvents); + }, + // Mutation finished, failed or successful + // Fetch anyway, just to be sure + onSettled: () => { + queryClient.invalidateQueries(RUNDOWN_TABLE); + }, + networkMode: 'always', + }); + + /** + * Swaps the schedule of two events + */ + const swapEvents = useCallback( + async ({ from, to }: SwapEntry) => { + // TODO: before calling `/swapEvents`, + // we should determine the events are of type `OntimeEvent` + try { + await _swapEvents.mutateAsync({ from, to }); + } catch (error) { + logAxiosError('Error re-ordering event', error); + } + }, + [_swapEvents], + ); + + return { + addEvent, + updateEvent, + deleteEvent, + deleteAllEvents, + applyDelay, + reorderEvent, + swapEvents, + }; }; diff --git a/apps/client/src/common/models/EventData.ts b/apps/client/src/common/models/EventData.ts index ff121d821..9add8fee4 100644 --- a/apps/client/src/common/models/EventData.ts +++ b/apps/client/src/common/models/EventData.ts @@ -2,6 +2,7 @@ import { EventData } from 'ontime-types'; export const eventDataPlaceholder: EventData = { title: '', + description: '', publicUrl: '', publicInfo: '', backstageUrl: '', diff --git a/apps/client/src/common/stores/editorSettings.ts b/apps/client/src/common/stores/editorSettings.ts new file mode 100644 index 000000000..c4922303a --- /dev/null +++ b/apps/client/src/common/stores/editorSettings.ts @@ -0,0 +1,67 @@ +import { create } from 'zustand'; + +import { booleanFromLocalStorage } from '../utils/localStorage'; + +type EditorSettings = { + showQuickEntry: boolean; + startTimeIsLastEnd: boolean; + defaultPublic: boolean; + showNif: boolean; +}; + +type EditorSettingsStore = { + eventSettings: EditorSettings; + setLocalEventSettings: (newState: EditorSettings) => void; + setShowQuickEntry: (showQuickEntry: boolean) => void; + setStartTimeIsLastEnd: (startTimeIsLastEnd: boolean) => void; + setDefaultPublic: (defaultPublic: boolean) => void; + setShowNif: (showNif: boolean) => void; +}; + +enum EditorSettingsKeys { + ShowQuickEntry = 'ontime-show-quick-entry', + StartTimeIsLastEnd = 'ontime-start-is-last-end', + DefaultPublic = 'ontime-default-public', + ShowNif = 'ontime-show-nif', +} + +export const useEditorSettings = create((set) => ({ + eventSettings: { + showQuickEntry: booleanFromLocalStorage(EditorSettingsKeys.ShowQuickEntry, false), + startTimeIsLastEnd: booleanFromLocalStorage(EditorSettingsKeys.ShowQuickEntry, true), + defaultPublic: booleanFromLocalStorage(EditorSettingsKeys.ShowQuickEntry, true), + showNif: booleanFromLocalStorage(EditorSettingsKeys.ShowNif, true), + }, + + setLocalEventSettings: (value) => + set(() => { + localStorage.setItem(EditorSettingsKeys.ShowQuickEntry, String(value.showQuickEntry)); + localStorage.setItem(EditorSettingsKeys.StartTimeIsLastEnd, String(value.startTimeIsLastEnd)); + 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 } }; + }), + + setStartTimeIsLastEnd: (startTimeIsLastEnd) => + set((state) => { + localStorage.setItem(EditorSettingsKeys.StartTimeIsLastEnd, String(startTimeIsLastEnd)); + return { eventSettings: { ...state.eventSettings, startTimeIsLastEnd } }; + }), + + setDefaultPublic: (defaultPublic) => + set((state) => { + localStorage.setItem(EditorSettingsKeys.DefaultPublic, String(defaultPublic)); + return { eventSettings: { ...state.eventSettings, defaultPublic } }; + }), + + setShowNif: (showNif) => + set((state) => { + localStorage.setItem(EditorSettingsKeys.ShowNif, String(showNif)); + return { eventSettings: { ...state.eventSettings, showNif } }; + }), +})); diff --git a/apps/client/src/common/stores/localEvent.ts b/apps/client/src/common/stores/localEvent.ts deleted file mode 100644 index d6b9a3dcd..000000000 --- a/apps/client/src/common/stores/localEvent.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { create } from 'zustand'; - -import { booleanFromLocalStorage } from '../utils/localStorage'; - -type EventSettings = { - showQuickEntry: boolean; - startTimeIsLastEnd: boolean; - defaultPublic: boolean; -}; - -type LocalEventStore = { - eventSettings: EventSettings; - setLocalEventSettings: (newState: EventSettings) => void; - setShowQuickEntry: (showQuickEntry: boolean) => void; - setStartTimeIsLastEnd: (startTimeIsLastEnd: boolean) => void; - setDefaultPublic: (defaultPublic: boolean) => void; -}; - -enum LocalEventKeys { - ShowQuickEntry = 'ontime-show-quick-entry', - StartTimeIsLastEnd = 'ontime-start-is-last-end', - DefaultPublic = 'ontime-default-public', -} - -export const useLocalEvent = create((set) => ({ - eventSettings: { - showQuickEntry: booleanFromLocalStorage(LocalEventKeys.ShowQuickEntry, false), - startTimeIsLastEnd: booleanFromLocalStorage(LocalEventKeys.ShowQuickEntry, true), - defaultPublic: booleanFromLocalStorage(LocalEventKeys.ShowQuickEntry, true), - }, - - setLocalEventSettings: (value) => - set(() => { - localStorage.setItem(LocalEventKeys.ShowQuickEntry, String(value.showQuickEntry)); - localStorage.setItem(LocalEventKeys.StartTimeIsLastEnd, String(value.startTimeIsLastEnd)); - localStorage.setItem(LocalEventKeys.DefaultPublic, String(value.defaultPublic)); - return { eventSettings: value }; - }), - - setShowQuickEntry: (showQuickEntry) => - set((state) => { - localStorage.setItem(LocalEventKeys.ShowQuickEntry, String(showQuickEntry)); - return { eventSettings: { ...state.eventSettings, showQuickEntry } }; - }), - - setStartTimeIsLastEnd: (startTimeIsLastEnd) => - set((state) => { - localStorage.setItem(LocalEventKeys.StartTimeIsLastEnd, String(startTimeIsLastEnd)); - return { eventSettings: { ...state.eventSettings, startTimeIsLastEnd } }; - }), - - setDefaultPublic: (defaultPublic) => - set((state) => { - localStorage.setItem(LocalEventKeys.DefaultPublic, String(defaultPublic)); - return { eventSettings: { ...state.eventSettings, defaultPublic } }; - }), -})); diff --git a/apps/client/src/common/utils/__tests__/aliases.test.js b/apps/client/src/common/utils/__tests__/aliases.test.js index 9dee998d5..5a50a004b 100644 --- a/apps/client/src/common/utils/__tests__/aliases.test.js +++ b/apps/client/src/common/utils/__tests__/aliases.test.js @@ -1,4 +1,5 @@ -import { validateAlias } from '../aliases'; +import { resolvePath } from 'react-router-dom'; +import { validateAlias, generateURLFromAlias, getAliasRoute } from '../aliases'; describe('An alias fails if incorrect', () => { const testsToFail = [ @@ -23,3 +24,79 @@ describe('An alias fails if incorrect', () => { }), ); }); +describe('generateURLFromAlias and getAliasRoute function', () => { + test('generate the expected url from an alias', () => { + const testData = [ + { + enabled: true, + alias: 'demopage', + pathAndParams: '/timer?user=guest', + }, + ]; + + const expected = [ + { + url: '/timer?user=guest&alias=demopage', + }, + ]; + + expect(generateURLFromAlias(testData[0])).toStrictEqual(expected[0].url); + }); + test('generate the url to redirect to when the current URL is just the alias', () => { + const aliases = [ + { + enabled: true, + alias: 'demopage', + pathAndParams: '/timer?user=guest', + }, + ]; + // let current location be the alias + const location = resolvePath(aliases[0].alias); + + const expected = [ + { + url: '/timer?user=guest&alias=demopage', + }, + ]; + + expect(getAliasRoute(location, aliases, null)).toStrictEqual(expected[0].url); + }); + test('generate the url to redirect to when the current URL the same url but with a change of params', () => { + const aliases = [ + { + enabled: true, + alias: 'demopage', + pathAndParams: '/timer?user=guest', + }, + ]; + // let current location be the actual url with alias attached to it + const location = resolvePath(aliases[0].pathAndParams); + const urlSearchParams = new URLSearchParams(location.search); + urlSearchParams.append('alias', aliases[0].alias); // + + // update current alias with extra param + aliases[0].pathAndParams += '&eventId=674'; + const expected = [ + { + url: '/timer?user=guest&eventId=674&alias=demopage', + }, + ]; + + expect(getAliasRoute(location, aliases, urlSearchParams)).toStrictEqual(expected[0].url); + }); + test('generate no url to redirect to when the current URL the same url', () => { + const aliases = [ + { + enabled: true, + alias: 'demopage', + pathAndParams: '/timer?user=guest', + }, + ]; + // let current location be the actual url with alias attached to it + const location = resolvePath(aliases[0].pathAndParams); + const urlSearchParams = new URLSearchParams(location.search); + urlSearchParams.append('alias', aliases[0].alias); // + + expect(getAliasRoute(location, aliases, urlSearchParams)).toBeNull(); + }); +}); diff --git a/apps/client/src/common/utils/__tests__/eventsManager.test.js b/apps/client/src/common/utils/__tests__/eventsManager.test.js deleted file mode 100644 index a65f9853b..000000000 --- a/apps/client/src/common/utils/__tests__/eventsManager.test.js +++ /dev/null @@ -1,536 +0,0 @@ -import { formatEventList, getEventsWithDelay, trimRundown } from '../eventsManager'; - -describe('getEventsWithDelay function', () => { - test('with positive delays', () => { - const testData = [ - { - title: 'Welcome to Ontime', - timeStart: 28800000, - timeEnd: 30600000, - colour: '', - type: 'event', - id: '5946', - }, - { - duration: 60000, - type: 'delay', - id: '24240', - }, - { - title: 'Unless recalled by the OSC address', - timeStart: 34920000, - timeEnd: 35520000, - colour: '', - type: 'event', - id: '8ee5', - }, - { - title: 'Use simpler times to create a timer', - timeStart: 120000, - timeEnd: 720000, - colour: '', - type: 'event', - id: '8222', - }, - { - duration: 900000, - type: 'delay', - revision: 0, - id: 'a386', - }, - { - title: 'Add delay blocks to affect all events', - timeStart: 37320000, - timeEnd: 38520000, - colour: '', - type: 'event', - id: '6dce', - }, - { - title: 'Add and remove events with [+] and [-]', - timeStart: 38520000, - timeEnd: 45120000, - colour: '', - type: 'event', - id: '2651', - }, - { - type: 'block', - id: 'e6a1', - }, - { - title: 'And control whether they are public', - timeStart: 46800000, - timeEnd: 57600000, - colour: '', - type: 'event', - id: '1358', - }, - ]; - - const expected = [ - { - title: 'Welcome to Ontime', - timeStart: 28800000, - timeEnd: 30600000, - colour: '', - type: 'event', - id: '5946', - }, - { - title: 'Unless recalled by the OSC address', - timeStart: 34920000 + 60000, - timeEnd: 35520000 + 60000, - colour: '', - type: 'event', - id: '8ee5', - }, - { - title: 'Use simpler times to create a timer', - timeStart: 120000 + 60000, - timeEnd: 720000 + 60000, - colour: '', - type: 'event', - id: '8222', - }, - { - title: 'Add delay blocks to affect all events', - timeStart: 37320000 + 60000 + 900000, - timeEnd: 38520000 + 60000 + 900000, - colour: '', - type: 'event', - id: '6dce', - }, - { - title: 'Add and remove events with [+] and [-]', - timeStart: 38520000 + 60000 + 900000, - timeEnd: 45120000 + 60000 + 900000, - colour: '', - type: 'event', - id: '2651', - }, - { - title: 'And control whether they are public', - timeStart: 46800000, - timeEnd: 57600000, - colour: '', - type: 'event', - id: '1358', - }, - ]; - - expect(getEventsWithDelay(testData)).toStrictEqual(expected); - }); - test('with negative delays', () => { - const testData = [ - { - duration: -20, - type: 'delay', - id: '24240', - }, - { - title: 'Welcome to Ontime', - timeStart: 100, - timeEnd: 200, - colour: '', - type: 'event', - id: '5946', - }, - ]; - - const expected = [ - { - title: 'Welcome to Ontime', - timeStart: 80, - timeEnd: 180, - colour: '', - type: 'event', - id: '5946', - }, - ]; - - expect(getEventsWithDelay(testData)).toStrictEqual(expected); - }); -}); - -describe('getEventsWithDelay edge cases', () => { - it('ensures time start cannot be below 0', () => { - const testData = [ - { - duration: -200, - type: 'delay', - id: '24240', - }, - { - title: 'Welcome to Ontime', - timeStart: 10, - timeEnd: 20, - colour: '', - type: 'event', - id: '5946', - }, - ]; - - const expected = [ - { - title: 'Welcome to Ontime', - timeStart: 0, - timeEnd: 0, - colour: '', - type: 'event', - id: '5946', - }, - ]; - - expect(getEventsWithDelay(testData)).toStrictEqual(expected); - }); - it('does not modify original array', () => { - const testData = [ - { - duration: 10, - type: 'delay', - id: '24240', - }, - { - title: 'Welcome to Ontime', - timeStart: 10, - timeEnd: 20, - colour: '', - type: 'event', - id: '5946', - }, - ]; - - const expected = [ - { - title: 'Welcome to Ontime', - timeStart: 20, - timeEnd: 30, - colour: '', - type: 'event', - id: '5946', - }, - ]; - - const expectedSafe = [ - { - title: 'Welcome to Ontime', - timeStart: 20, - timeEnd: 30, - colour: '', - type: 'event', - id: '5946', - }, - ]; - - expect(getEventsWithDelay(testData)).toStrictEqual(expected); - expect(getEventsWithDelay(expectedSafe)).toStrictEqual(expected); - }); - - it('given an empty array', () => { - const emptyArray = { - test: [], - expect: [], - }; - - expect(getEventsWithDelay(emptyArray.test)).toStrictEqual(emptyArray.expect); - }); - - it('given an undefined object', () => { - const withUndefined = { - test: undefined, - expect: [], - }; - - expect(getEventsWithDelay(withUndefined.test)).toStrictEqual(withUndefined.expect); - }); - - it('given a corrupted event object', () => { - const testData = [ - { - title: 'Welcome to Ontime', - timeEnd: 30600000, - colour: '', - type: 'event', - id: '5946', - }, - { - duration: 60000, - type: 'delay', - id: '24240', - }, - { - title: 'Unless recalled by the OSC address', - timeStart: 34920000, - timeEnd: 35520000, - colour: '', - type: 'event', - id: '8ee5', - }, - ]; - const expected = [ - { - title: 'Welcome to Ontime', - timeEnd: 30600000, - colour: '', - type: 'event', - id: '5946', - }, - { - title: 'Unless recalled by the OSC address', - timeStart: 34920000 + 60000, - timeEnd: 35520000 + 60000, - colour: '', - type: 'event', - id: '8ee5', - }, - ]; - - expect(getEventsWithDelay(testData)).toStrictEqual(expected); - }); - - it('given a corrupted delay object', () => { - const testData = [ - { - title: 'Welcome to Ontime', - timeStart: 28800000, - timeEnd: 30600000, - colour: '', - type: 'event', - id: '5946', - }, - { - type: 'delay', - id: '24240', - }, - { - title: 'Unless recalled by the OSC address', - timeStart: 34920000, - timeEnd: 35520000, - colour: '', - type: 'event', - id: '8ee5', - }, - ]; - const expected = [ - { - title: 'Welcome to Ontime', - timeStart: 28800000, - timeEnd: 30600000, - colour: '', - type: 'event', - id: '5946', - }, - { - title: 'Unless recalled by the OSC address', - timeStart: 34920000, - timeEnd: 35520000, - colour: '', - type: 'event', - id: '8ee5', - }, - ]; - - expect(getEventsWithDelay(testData)).toStrictEqual(expected); - }); -}); - -describe('test trimEventlist function', () => { - const limit = 8; - const testData = [ - { id: '1' }, - { id: '2' }, - { id: '3' }, - { id: '4' }, - { id: '5' }, - { id: '6' }, - { id: '7' }, - { id: '8' }, - { id: '9' }, - { id: '10' }, - { id: '11' }, - { id: '12' }, - ]; - - it('when we use the first item', () => { - const selectedId = '1'; - const expected = [ - { id: '1' }, - { id: '2' }, - { id: '3' }, - { id: '4' }, - { id: '5' }, - { id: '6' }, - { id: '7' }, - { id: '8' }, - ]; - - const l = trimRundown(testData, selectedId, limit); - expect(l.length).toBe(limit); - expect(l).toStrictEqual(expected); - }); - - it('when we use the third item', () => { - const selectedId = '3'; - const expected = [ - { id: '1' }, - { id: '2' }, - { id: '3' }, - { id: '4' }, - { id: '5' }, - { id: '6' }, - { id: '7' }, - { id: '8' }, - ]; - - const l = trimRundown(testData, selectedId, limit); - expect(l.length).toBe(limit); - expect(l).toStrictEqual(expected); - }); - - it('when we use the fourth item', () => { - const selectedId = '4'; - const expected = [ - { id: '2' }, - { id: '3' }, - { id: '4' }, - { id: '5' }, - { id: '6' }, - { id: '7' }, - { id: '8' }, - { id: '9' }, - ]; - - const l = trimRundown(testData, selectedId, limit); - expect(l.length).toBe(limit); - expect(l).toStrictEqual(expected); - }); - - it('if selected is not found', () => { - const selectedId = '15'; - const expected = [ - { id: '1' }, - { id: '2' }, - { id: '3' }, - { id: '4' }, - { id: '5' }, - { id: '6' }, - { id: '7' }, - { id: '8' }, - ]; - - const l = trimRundown(testData, selectedId, limit); - expect(l.length).toBe(limit); - expect(l).toStrictEqual(expected); - }); -}); - -describe('test formatEvents function', () => { - const testEvent = [ - { - title: 'Welcome to Ontime', - subtitle: 'Subtitles are useful', - presenter: 'cpvalente', - note: 'Maybe a running note for the operator?', - timeStart: 28800000, - timeEnd: 30600000, - isPublic: false, - colour: '', - type: 'event', - revision: 0, - id: '5946', - }, - { - title: 'Unless recalled by the OSC address', - subtitle: '', - presenter: '', - note: 'In green, below', - timeStart: 34800000, - timeEnd: 35400000, - isPublic: false, - colour: '', - type: 'event', - revision: 0, - id: '8ee5', - }, - ]; - - it('it parses correctly', () => { - const selectedId = 'otherEvent'; - const nextId = 'notHere'; - const expected = [ - { - id: '5946', - time: '08:00 - 08:30', - title: 'Welcome to Ontime', - isNow: false, - isNext: false, - colour: '', - }, - { - id: '8ee5', - time: '09:40 - 09:50', - title: 'Unless recalled by the OSC address', - isNow: false, - isNext: false, - colour: '', - }, - ]; - - const parsed = formatEventList(testEvent, selectedId, nextId, { showEnd: true }); - expect(parsed).toStrictEqual(expected); - }); - - it('it handles selected correctly', () => { - const selectedId = '5946'; - const nextId = '8ee5'; - const expected = [ - { - id: '5946', - time: '08:00 - 08:30', - title: 'Welcome to Ontime', - isNow: true, - isNext: false, - colour: '', - }, - { - id: '8ee5', - time: '09:40 - 09:50', - title: 'Unless recalled by the OSC address', - isNow: false, - isNext: true, - colour: '', - }, - ]; - - const parsed = formatEventList(testEvent, selectedId, nextId, { showEnd: true }); - expect(parsed).toStrictEqual(expected); - }); - - it('it handles next correctly', () => { - const selectedId = '8ee5'; - const nextId = 'notHere'; - - const expected = [ - { - id: '5946', - time: '08:00 - 08:30', - title: 'Welcome to Ontime', - isNow: false, - isNext: false, - colour: '', - }, - { - id: '8ee5', - time: '09:40 - 09:50', - title: 'Unless recalled by the OSC address', - isNow: true, - isNext: false, - colour: '', - }, - ]; - - const parsed = formatEventList(testEvent, selectedId, nextId, { showEnd: true }); - expect(parsed).toStrictEqual(expected); - }); -}); diff --git a/apps/client/src/common/utils/__tests__/eventsManager.test.ts b/apps/client/src/common/utils/__tests__/eventsManager.test.ts new file mode 100644 index 000000000..140b9ff3d --- /dev/null +++ b/apps/client/src/common/utils/__tests__/eventsManager.test.ts @@ -0,0 +1,55 @@ +import { EndAction, OntimeEvent, SupportedEvent, TimerType } from 'ontime-types'; + +import { cloneEvent } from '../eventsManager'; + +describe('cloneEvent()', () => { + it('creates a stem from a given event', () => { + const original = { + id: 'unique', + type: SupportedEvent.Event, + title: 'title', + cue: 'cue', + subtitle: 'subtitle', + presenter: 'presenter', + note: 'note', + timeStart: 0, + duration: 10, + timeEnd: 10, + timerType: TimerType.CountDown, + endAction: EndAction.None, + isPublic: false, + skip: false, + colour: 'F00', + revision: 10, + user0: 'user0', + user1: 'user1', + user2: 'user2', + user3: 'user3', + user4: 'user4', + user5: 'user5', + user6: 'user6', + user7: 'user7', + user8: 'user8', + user9: 'user9', + } as OntimeEvent; + + const cloned = cloneEvent(original); + expect(cloned).not.toBe(original); + // @ts-expect-error -- safeguarding this + expect(cloned?.id).toBe(undefined); + expect(cloned.title).toBe(original.title); + expect(cloned.subtitle).toBe(original.subtitle); + expect(cloned.presenter).toBe(original.presenter); + expect(cloned.note).toBe(original.note); + expect(cloned.endAction).toBe(original.endAction); + expect(cloned.timerType).toBe(original.timerType); + expect(cloned.timeStart).toBe(original.timeStart); + expect(cloned.timeEnd).toBe(original.timeEnd); + expect(cloned.duration).toBe(original.duration); + expect(cloned.isPublic).toBe(original.isPublic); + expect(cloned.skip).toBe(original.skip); + expect(cloned.colour).toBe(original.colour); + expect(cloned.type).toBe(SupportedEvent.Event); + expect(cloned.revision).toBe(0); + }); +}); diff --git a/apps/client/src/common/utils/__tests__/getDelayTo.test.js b/apps/client/src/common/utils/__tests__/getDelayTo.test.js deleted file mode 100644 index 8ba459818..000000000 --- a/apps/client/src/common/utils/__tests__/getDelayTo.test.js +++ /dev/null @@ -1,56 +0,0 @@ -import getDelayTo from '../getDelayTo'; - -describe('getDelayTo function', () => { - it('handles list with delays', () => { - const delayDuration = 100; - const events = [ - { type: 'event' }, - { type: 'delay', duration: delayDuration }, - { type: 'event' }, - ]; - - const notDelayed = getDelayTo(events, 0); - expect(notDelayed).toBe(0); - const delayedEvent = getDelayTo(events, 2); - expect(delayedEvent).toBe(delayDuration); - }); - it('handles list without delays', () => { - const events = [{ type: 'event' }, { type: 'event' }]; - const notDelayed = getDelayTo(events, 1); - expect(notDelayed).toBe(0); - }); - - it('handles list with multiple delays', () => { - const delayDuration = 100; - const events = [ - { type: 'event' }, - { type: 'delay', duration: delayDuration }, - { type: 'event' }, - { type: 'delay', duration: delayDuration }, - { type: 'event' }, - ]; - const doubleDelay = getDelayTo(events, 4); - expect(doubleDelay).toBe(delayDuration * 2); - }); - it('handles list with blocks', () => { - const events = [ - { type: 'event' }, - { type: 'delay', duration: 100 }, - { type: 'event' }, - { type: 'block' }, - { type: 'event' }, - ]; - const notDelayed = getDelayTo(events, 4); - expect(notDelayed).toBe(0); - }); - it('handles index greater than list', () => { - const events = [{ type: 'event' }, { type: 'delay', duration: 100 }, { type: 'event' }]; - const notDelayed = getDelayTo(events, 3); - expect(notDelayed).toBe(0); - }); - it('handles negative index (not found)', () => { - const events = [{ type: 'event' }, { type: 'delay', duration: 100 }, { type: 'event' }]; - const notDelayed = getDelayTo(events, -1); - expect(notDelayed).toBe(0); - }); -}); diff --git a/apps/client/src/common/utils/__tests__/timesManager.test.ts b/apps/client/src/common/utils/__tests__/timesManager.test.ts deleted file mode 100644 index d8ac400e7..000000000 --- a/apps/client/src/common/utils/__tests__/timesManager.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { calculateDuration, DAY_TO_MS } from '../timesManager'; - -describe('calculateDuration()', () => { - describe('Given start and end values', () => { - it('calculates duration correctly', () => { - const testStart = 1; - const testEnd = 2; - const val = calculateDuration(testStart, testEnd); - expect(val).toBe(testEnd - testStart); - }); - }); - - describe('Handles edge cases', () => { - it('when start is after end', () => { - const testStart = 3; - const testEnd = 2; - const val = calculateDuration(testStart, testEnd); - expect(val).toBe(testEnd + DAY_TO_MS - testStart); - }); - it('when both are equal', () => { - const testStart = 1; - const testEnd = 1; - const val = calculateDuration(testStart, testEnd); - expect(val).toBe(testEnd - testStart); - }); - }); -}); diff --git a/apps/client/src/common/utils/aliases.ts b/apps/client/src/common/utils/aliases.ts index a6a56bb11..0f9e1e4c6 100644 --- a/apps/client/src/common/utils/aliases.ts +++ b/apps/client/src/common/utils/aliases.ts @@ -1,10 +1,13 @@ +import isEqual from 'react-fast-compare'; +import { Location, resolvePath } from 'react-router-dom'; +import { Alias } from 'ontime-types'; + /** * Validates an alias against defined parameters * @param {string} alias * @returns {{message: string, status: boolean}} */ export const validateAlias = (alias: string) => { - const valid = { status: true, message: 'ok' }; if (alias === '' || alias == null) { @@ -26,4 +29,49 @@ export const validateAlias = (alias: string) => { } return valid; -}; \ No newline at end of file +}; + +/** + * Gets the URL to send an alias to + * @param location + * @param data + * @param searchParams + */ +export const getAliasRoute = (location: Location, data: Alias[], searchParams: URLSearchParams) => { + const currentURL = location.pathname.substring(1); + // we need to check if the whole url here is an alias, so we can redirect + const foundAlias = data.filter((d) => d.alias === currentURL && d.enabled)[0]; + if (foundAlias) { + return generateURLFromAlias(foundAlias); + } + const aliasOnPage = searchParams.get('alias'); + for (const d of data) { + if (aliasOnPage) { + // if the alias fits the alias on this page, but the URL is diferent, we redirect user to the new URL + // if we have the same alias and its enabled and its not empty + if (d.alias !== '' && d.enabled && d.alias === aliasOnPage) { + const newAliasPath = resolvePath(d.pathAndParams); + const urlParams = new URLSearchParams(newAliasPath.search); + urlParams.set('alias', d.alias); + // we confirm either the url parameters does not match or the url path doesnt + if (!isEqual(urlParams, searchParams) || newAliasPath.pathname !== location.pathname) { + // we then redirect to the alias route, since the view listening to this alias has an outdated URL + return `${newAliasPath.pathname}?${urlParams}`; + } + } + } + } + return null; +}; + +/** + * Generate URL from an alias + * @param aliasData + */ +export const generateURLFromAlias = (aliasData: Alias) => { + const newAliasPath = resolvePath(aliasData.pathAndParams); + const urlParams = new URLSearchParams(newAliasPath.search); + urlParams.set('alias', aliasData.alias); + + return `${newAliasPath.pathname}?${urlParams}`; +}; diff --git a/apps/client/src/common/utils/eventsManager.ts b/apps/client/src/common/utils/eventsManager.ts index 1edcf9172..31296a13c 100644 --- a/apps/client/src/common/utils/eventsManager.ts +++ b/apps/client/src/common/utils/eventsManager.ts @@ -1,174 +1,32 @@ -import { OntimeEvent, OntimeRundownEntry, SupportedEvent } from 'ontime-types'; - -import { formatTime } from './time'; - -/** - * @description From a list of events, returns only events of type event with calculated delays - * @param {Object[]} rundown - given rundown - * @returns {Object[]} Filtered events with calculated delays - */ - -export const getEventsWithDelay = (rundown: OntimeRundownEntry[]): OntimeEvent[] => { - if (rundown == null) return []; - - const delayedEvents: OntimeEvent[] = []; - - // Add running delay - let delay = 0; - for (const event of rundown) { - if (event.type === SupportedEvent.Block) delay = 0; - else if (event.type === SupportedEvent.Delay) { - if (typeof event.duration === 'number') { - delay += event.duration; - } - } else if (event.type === SupportedEvent.Event) { - const delayedEvent = { ...event }; - if (delay !== 0) { - delayedEvent.timeStart = Math.max(delayedEvent.timeStart + delay, 0); - delayedEvent.timeEnd = Math.max(delayedEvent.timeEnd + delay, 0); - } - delayedEvents.push(delayedEvent); - } - } - - return delayedEvents; -}; - -/** - * @description Returns trimmed event list array - * @param {Object[]} rundown - given rundown - * @param {string} selectedId - id of currently selected event - * @param {number} limit - max number of events to return - * @returns {Object[]} Event list with maximum objects - */ -export const trimRundown = (rundown: OntimeEvent[], selectedId: string, limit: number): OntimeEvent[] => { - if (rundown == null) return []; - - const BEFORE = 2; - const trimmedRundown = [...rundown]; - - // limit events length if necessary - if (limit != null) { - while (trimmedRundown.length > limit) { - const idx = trimmedRundown.findIndex((e) => e.id === selectedId); - if (idx <= BEFORE) { - trimmedRundown.pop(); - } else { - trimmedRundown.shift(); - } - } - } - return trimmedRundown; -}; - -type FormatEventListOptionsProp = { - showEnd?: boolean; -}; -/** - * @description Returns list of events formatted to be displayed - * @param {Object[]} rundown - given rundown - * @param {string} selectedId - id of currently selected event - * @param {string} nextId - id of next event - * @param {object} [options] - * @param {boolean} [options.showEnd] - whether to show the end time - * @returns {Object[]} Formatted list of events [{time: -, title: -, isNow, isNext}] - */ -export const formatEventList = ( - rundown: OntimeEvent[], - selectedId: string, - nextId: string, - options: FormatEventListOptionsProp, -): ScheduleEvent[] => { - if (rundown == null) return []; - const { showEnd = false } = options; - - const givenEvents = [...rundown]; - - // format list - const formattedEvents = []; - for (const event of givenEvents) { - const start = formatTime(event.timeStart); - const end = formatTime(event.timeEnd); - - formattedEvents.push({ - id: event.id, - time: showEnd ? `${start} - ${end}` : start, - title: event.title, - isNow: event.id === selectedId, - isNext: event.id === nextId, - colour: event.colour, - }); - } - - return formattedEvents; -}; - -export type ScheduleEvent = { - id: string; - time: string; - title: string; - isNow: boolean; - isNext: boolean; - colour: string; -}; +import { OntimeEvent, SupportedEvent } from 'ontime-types'; /** * @description Creates a safe duplicate of an event - * @param {object} event - * @return {object} clean event + * @param {OntimeEvent} event + * @param {string} [after] + * @return {OntimeEvent} clean event */ -type ClonedEvent = OntimeEvent | { after?: string }; +type ClonedEvent = Omit< + OntimeEvent, + 'id' | 'user0' | 'user1' | 'user2' | 'user3' | 'user4' | 'user5' | 'user6' | 'user7' | 'user8' | 'user9' +>; export const cloneEvent = (event: OntimeEvent, after?: string): ClonedEvent => { return { type: SupportedEvent.Event, title: event.title, + cue: event.cue, subtitle: event.subtitle, presenter: event.presenter, note: event.note, timeStart: event.timeStart, + duration: event.duration, timeEnd: event.timeEnd, + timerType: event.timerType, + endAction: event.endAction, isPublic: event.isPublic, skip: event.skip, colour: event.colour, after: after, + revision: 0, }; }; - -/** - * Gets first event in rundown, if it exists - * @param {OntimeRundownEntry[]} rundown - * @return {OntimeEvent | null} - */ -export function getFirstEvent(rundown: OntimeRundownEntry[]) { - return rundown.length ? rundown[0] : null; -} - -/** - * Gets next event in rundown, if it exists - * @param {OntimeRundownEntry[]} rundown - * @param {string} currentId - * @return {OntimeEvent | null} - */ -export function getNextEvent(rundown: OntimeRundownEntry[], currentId: string) { - const index = rundown.findIndex((event) => event.id === currentId); - if (index !== -1 && index + 1 < rundown.length) { - return rundown[index + 1]; - } else { - return null; - } -} - -/** - * Gets previous event in rundown, if it exists - * @param {OntimeRundownEntry[]} rundown - * @param {string} currentId - * @return {OntimeEvent | null} - */ -export function getPreviousEvent(rundown: OntimeRundownEntry[], currentId: string) { - const index = rundown.findIndex((event) => event.id === currentId); - if (index !== -1 && index - 1 >= 0) { - return rundown[index - 1]; - } else { - return null; - } -} diff --git a/apps/client/src/common/utils/getDelayTo.js b/apps/client/src/common/utils/getDelayTo.js deleted file mode 100644 index a6802a0d4..000000000 --- a/apps/client/src/common/utils/getDelayTo.js +++ /dev/null @@ -1,25 +0,0 @@ -/** - * @description calculates delay to a given event - * @param {array} events - * @param {number} eventIndex - * @return {number} - delay value of given event - */ -export default function getDelayTo(events, eventIndex) { - let delay = 0; - let index = 0; - if (eventIndex >= 0) { - for (const event of events) { - if (eventIndex === index) { - return delay; - } - - if (event.type === 'delay') { - delay += event.duration; - } else if (event.type === 'block') { - delay = 0; - } - index++; - } - } - return 0; -} diff --git a/apps/client/src/common/utils/timeConstants.js b/apps/client/src/common/utils/timeConstants.js index 39a5c34aa..10ed97a8e 100644 --- a/apps/client/src/common/utils/timeConstants.js +++ b/apps/client/src/common/utils/timeConstants.js @@ -10,15 +10,8 @@ export const mts = 1000; */ export const mtm = 1000 * 60; - /** * millis to hours * @type {number} */ export const mth = 1000 * 60 * 60; - -/** - * milliseconds in a day - * @type {number} - */ -export const DAY_TO_MS = 86400000; diff --git a/apps/client/src/common/utils/timesManager.ts b/apps/client/src/common/utils/timesManager.ts index 435fdf1ab..4c17af915 100644 --- a/apps/client/src/common/utils/timesManager.ts +++ b/apps/client/src/common/utils/timesManager.ts @@ -1,16 +1,5 @@ export type TimeEntryField = 'timeStart' | 'timeEnd' | 'durationOverride'; -/** - * @description Milliseconds in a day - */ -export const DAY_TO_MS = 86400000; - -/** - * @description calculates duration from given values - */ -export const calculateDuration = (start: number, end: number): number => - start > end ? end + DAY_TO_MS - start : end - start; - /** * @description Checks which field the value relates to */ diff --git a/apps/client/src/features/AliasWrapper.tsx b/apps/client/src/features/AliasWrapper.tsx new file mode 100644 index 000000000..f6808edab --- /dev/null +++ b/apps/client/src/features/AliasWrapper.tsx @@ -0,0 +1,29 @@ +/* eslint-disable react/display-name */ +import { ComponentType, useEffect } from 'react'; +import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'; + +import useAliases from '../common/hooks-query/useAliases'; +import { getAliasRoute } from '../common/utils/aliases'; + +const withAlias =

(Component: ComponentType

) => { + return (props: Partial

) => { + const { data } = useAliases(); + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + const location = useLocation(); + + // navigate if is alias route + useEffect(() => { + if (!data) return; + const url = getAliasRoute(location, data, searchParams); + // navigate to this route if its not empty + if (url) { + navigate(url); + } + }, [data, searchParams, navigate, location]); + + return ; + }; +}; + +export default withAlias; diff --git a/apps/client/src/features/cuesheet/Cuesheet.tsx b/apps/client/src/features/cuesheet/Cuesheet.tsx index bd863aedc..f8806e65f 100644 --- a/apps/client/src/features/cuesheet/Cuesheet.tsx +++ b/apps/client/src/features/cuesheet/Cuesheet.tsx @@ -12,7 +12,7 @@ import { } from '@dnd-kit/core'; import { horizontalListSortingStrategy, SortableContext, sortableKeyboardCoordinates } from '@dnd-kit/sortable'; import { ColumnDef, flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table'; -import { OntimeBlock, OntimeDelay, OntimeEvent, OntimeRundown, OntimeRundownEntry, SupportedEvent } from 'ontime-types'; +import { isOntimeBlock, isOntimeDelay, isOntimeEvent, OntimeRundown, OntimeRundownEntry } from 'ontime-types'; import { useLocalStorage } from '../../common/hooks/useLocalStorage'; import { millisToDelayString } from '../../common/utils/dateConfig'; @@ -196,15 +196,14 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu {table.getRowModel().rows.map((row) => { - const entryType = row.original.type as SupportedEvent; const key = row.original.id; const isSelected = selectedId === key; if (isSelected) { isPast = false; } - if (entryType === SupportedEvent.Block) { - const title = (row.original as OntimeBlock).title; + if (isOntimeBlock(row.original)) { + const title = row.original.title; return ( @@ -212,8 +211,8 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu ); } - if (entryType === SupportedEvent.Delay) { - const delayVal = (row.original as OntimeDelay).duration; + if (isOntimeDelay(row.original)) { + const delayVal = row.original.duration; if (!showDelayBlock || delayVal === 0) { return null; @@ -226,7 +225,7 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu ); } - if (entryType === SupportedEvent.Event) { + if (isOntimeEvent(row.original)) { eventIndex++; const isSelected = key === selectedId; if (isSelected) { @@ -238,9 +237,9 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu } const bgFallback = 'transparent'; - const bgColour = (row.original as OntimeEvent).colour || bgFallback; + const bgColour = row.original.colour || bgFallback; const textColour = bgColour === bgFallback ? undefined : getAccessibleColour(bgColour); - const isSkipped = (row.original as OntimeEvent).skip; + const isSkipped = row.original.skip; let rowBgColour: string | undefined; if (row.original.id === selectedId) { diff --git a/apps/client/src/features/cuesheet/cuesheetCols.tsx b/apps/client/src/features/cuesheet/cuesheetCols.tsx index 93088429f..13446f305 100644 --- a/apps/client/src/features/cuesheet/cuesheetCols.tsx +++ b/apps/client/src/features/cuesheet/cuesheetCols.tsx @@ -49,6 +49,13 @@ function MakeUserField({ getValue, row: { index }, column: { id }, table }: Cell export function makeCuesheetColumns(userFields?: UserFields): ColumnDef[] { return [ + { + accessorKey: 'cue', + id: 'cue', + header: 'Cue', + cell: (row) => row.getValue(), + size: 75, + }, { accessorKey: 'isPublic', id: 'isPublic', diff --git a/apps/client/src/features/cuesheet/defaults.ts b/apps/client/src/features/cuesheet/defaults.ts index 842592b7a..fd6aac5b9 100644 --- a/apps/client/src/features/cuesheet/defaults.ts +++ b/apps/client/src/features/cuesheet/defaults.ts @@ -5,6 +5,7 @@ import { OntimeEntryCommonKeys, OntimeEvent } from 'ontime-types'; */ export const defaultColumnOrder: OntimeEntryCommonKeys[] = [ 'isPublic', + 'cue', 'timeStart', 'timeEnd', 'duration', diff --git a/apps/client/src/features/editors/Editor.module.scss b/apps/client/src/features/editors/Editor.module.scss index 47b7c7c96..acdd490c0 100644 --- a/apps/client/src/features/editors/Editor.module.scss +++ b/apps/client/src/features/editors/Editor.module.scss @@ -215,6 +215,11 @@ $playback-width: 26rem; flex-direction: column; } + +.mainContainer > .rundown { + padding: 1rem 0; +} + .content { padding-top: 1.5rem; } diff --git a/apps/client/src/features/event-editor/EventEditor.module.scss b/apps/client/src/features/event-editor/EventEditor.module.scss index f759b40c0..9ea11948b 100644 --- a/apps/client/src/features/event-editor/EventEditor.module.scss +++ b/apps/client/src/features/event-editor/EventEditor.module.scss @@ -6,10 +6,14 @@ gap: max(1rem, 2vh); display: grid; - grid-template-areas: - 'eventInfo eventActions' - 'timeOptions titles'; - grid-template-columns: auto 1fr; + grid-template-areas: 'time left right'; + grid-template-columns: auto 1fr 1fr; +} + +.timeOptions { + grid-area: time; + display: flex; + gap: 1.5rem; .timers, .timeSettings { @@ -19,51 +23,23 @@ } } -.eventInfo { - grid-area: eventInfo; +.left, +.right { display: flex; - align-items: center; - - .eventId { - margin-left:$element-spacing; - } + flex-direction: column; + gap: 0.5rem; } -.eventActions { - grid-area: eventActions; - margin-left: auto; +.left { + grid-area: left; + padding: 0 1rem; + border-left: 1px solid $border-color-ondark; } -.timeOptions { - grid-area: timeOptions; - display: flex; - gap: 1.5rem; -} - -.titles { - grid-area: titles; - display: grid; - grid-template-areas: 'left right'; - grid-template-columns: 1fr 1fr; - - .left, - .right { - display: flex; - flex-direction: column; - gap: 8px; - } - - .left { - grid-area: left; - padding: 0 1rem; - border-left: 1px solid $border-color-ondark; - } - - .right { - padding-left: 1rem; - grid-area: right; - border-left: 1px solid $border-color-ondark; - } +.right { + padding-left: 1rem; + grid-area: right; + border-left: 1px solid $border-color-ondark; } @mixin input-label() { @@ -94,6 +70,12 @@ } } +.eventActions { + margin-left: auto; + display: flex; + gap: 0.5rem; +} + .spacer { height: 1.25rem; } @@ -104,6 +86,12 @@ gap: 1rem; } +.splitTwo { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1.5rem; +} + .column { display: flex; flex-direction: column; @@ -116,4 +104,4 @@ .fullHeight { height: 100% -} \ No newline at end of file +} diff --git a/apps/client/src/features/event-editor/EventEditor.tsx b/apps/client/src/features/event-editor/EventEditor.tsx index 24848c4e8..1163b9a01 100644 --- a/apps/client/src/features/event-editor/EventEditor.tsx +++ b/apps/client/src/features/event-editor/EventEditor.tsx @@ -1,38 +1,45 @@ -import { useEffect, useState } from 'react'; -import { OntimeEvent } from 'ontime-types'; +import { useCallback, useEffect, useState } from 'react'; +import { isOntimeEvent, OntimeEvent } from 'ontime-types'; import CopyTag from '../../common/components/copy-tag/CopyTag'; +import { useEventAction } from '../../common/hooks/useEventAction'; import useRundown from '../../common/hooks-query/useRundown'; import { useAppMode } from '../../common/stores/appModeStore'; -import getDelayTo from '../../common/utils/getDelayTo'; +import EventEditorDataLeft from './composite/EventEditorDataLeft'; +import EventEditorDataRight from './composite/EventEditorDataRight'; import EventEditorTimes from './composite/EventEditorTimes'; -import EventEditorTitles from './composite/EventEditorTitles'; import style from './EventEditor.module.scss'; export type EventEditorSubmitActions = keyof OntimeEvent; +export type EditorUpdateFields = 'cue' | 'title' | 'presenter' | 'subtitle' | 'note' | 'colour'; export default function EventEditor() { const openId = useAppMode((state) => state.editId); const { data } = useRundown(); + const { updateEvent } = useEventAction(); + const [event, setEvent] = useState(null); - const [delay, setDelay] = useState(0); useEffect(() => { if (!data || !openId) { + setEvent(null); return; } - const eventIndex = data.findIndex((event) => event.id === openId); - if (eventIndex > -1) { - const event = data[eventIndex]; - if (event.type === 'event') { - setDelay(getDelayTo(data, eventIndex)); - setEvent(data[eventIndex] as OntimeEvent); - } + const event = data.find((event) => event.id === openId); + if (event && isOntimeEvent(event)) { + setEvent(event); } - }, [data, event, openId]); + }, [data, openId]); + + const handleSubmit = useCallback( + (field: EditorUpdateFields, value: string) => { + updateEvent({ id: event?.id, [field]: value }); + }, + [event?.id, updateEvent], + ); if (!event) { return Loading...; @@ -40,34 +47,35 @@ export default function EventEditor() { return (

-
- Event ID - - {event.id} - -
-
- {`/ontime/gotoid/${event.id}`} -
- + + handleSubmit={handleSubmit} + > + {event.id} + {`/ontime/gotoid/${event.id}`} + {`/ontime/gotocue/${event.cue}`} +
); } diff --git a/apps/client/src/features/event-editor/composite/CountedTextArea.tsx b/apps/client/src/features/event-editor/composite/CountedTextArea.tsx index 5fb0a5528..225e1e65a 100644 --- a/apps/client/src/features/event-editor/composite/CountedTextArea.tsx +++ b/apps/client/src/features/event-editor/composite/CountedTextArea.tsx @@ -3,7 +3,7 @@ import { Textarea } from '@chakra-ui/react'; import useReactiveTextInput from '../../../common/components/input/text-input/useReactiveTextInput'; -import { TitleActions } from './EventEditorTitles'; +import { TitleActions } from './EventEditorDataLeft'; import style from '../EventEditor.module.scss'; @@ -24,7 +24,9 @@ export default function CountedTextArea(props: CountedTextAreaProps) { return (
- + {`${value.length} characters`}