refactor: normalise data (#756)

* chore: remove legal from bundle

* refactor: create normalised dataset

* refactor: cuesheet uses flat rundown

* refactor: multi-selection

* refactor: prevent stale data on server restart

* refactor: increase ID size

* chore: instrument operation

* chore: update csv tests

* fix: resolve directory to test-db (#758)
This commit is contained in:
Carlos Valente
2024-02-03 21:43:17 +01:00
committed by GitHub
parent 47a519bac1
commit 1890fc49d7
62 changed files with 1838 additions and 3341 deletions
+4 -4
View File
@@ -1,5 +1,5 @@
import axios from 'axios';
import { GetRundownCached, OntimeRundown, OntimeRundownEntry } from 'ontime-types';
import { OntimeEvent, OntimeRundown, OntimeRundownEntry, RundownCached } from 'ontime-types';
import { rundownURL } from './apiConstants';
@@ -7,7 +7,7 @@ import { rundownURL } from './apiConstants';
* @description HTTP request to fetch all events
* @return {Promise}
*/
export async function fetchCachedRundown(): Promise<GetRundownCached> {
export async function fetchCachedRundown(): Promise<RundownCached> {
const res = await axios.get(`${rundownURL}/cached`);
return res.data;
}
@@ -26,7 +26,7 @@ export async function fetchRundown(): Promise<OntimeRundown> {
* @description HTTP request to post new event
* @return {Promise}
*/
export async function requestPostEvent(data: OntimeRundownEntry) {
export async function requestPostEvent(data: Partial<OntimeRundownEntry>) {
return axios.post(rundownURL, data);
}
@@ -39,7 +39,7 @@ export async function requestPutEvent(data: Partial<OntimeRundownEntry>) {
}
type BatchEditEntry = {
data: Partial<OntimeRundownEntry>;
data: Partial<OntimeEvent>;
ids: string[];
};
@@ -1,15 +1,16 @@
import { useEffect, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { GetRundownCached } from 'ontime-types';
import { NormalisedRundown, OntimeRundown, RundownCached } from 'ontime-types';
import { queryRefetchInterval } from '../../ontimeConfig';
import { RUNDOWN } from '../api/apiConstants';
import { fetchCachedRundown } from '../api/eventsApi';
const cachedRundownPlaceholder = { rundown: [], revision: -1 };
// revision is -1 so that the remote revision is higher
const cachedRundownPlaceholder = { order: [] as string[], rundown: {} as NormalisedRundown, revision: -1 };
// TODO: can we leverage structural sharing to see if data has changed?
export default function useRundown() {
const { data, status, isError, refetch, isFetching } = useQuery<GetRundownCached>({
const { data, status, isError, refetch, isFetching } = useQuery<RundownCached>({
queryKey: RUNDOWN,
queryFn: fetchCachedRundown,
placeholderData: cachedRundownPlaceholder,
@@ -17,13 +18,24 @@ export default function useRundown() {
retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchInterval,
networkMode: 'always',
// structuralSharing: (oldData: GetRundownCached | undefined, newData: GetRundownCached) => {
// if (oldData === undefined) {
// return cachedRundownPlaceholder;
// }
// const hasDataChanged = oldData?.revision === newData.revision;
// return hasDataChanged ? oldData : newData;
// },
});
return { data: data?.rundown ?? [], status, isError, refetch, isFetching };
return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching };
}
export function useFlatRundown() {
const { data, status } = useRundown();
const [prevRevision, setPrevRevision] = useState<number>(-1);
const [flatRunDown, setFlatRunDown] = useState<OntimeRundown>([]);
// update data whenever the revision changes
useEffect(() => {
if (data.revision !== -1 && data.revision !== prevRevision) {
const flatRundown = data.order.map((id) => data.rundown[id]);
setFlatRunDown(flatRundown);
setPrevRevision(data.revision);
}
}, [data.order, data.revision, data.rundown, prevRevision]);
return { data: flatRunDown, status };
}
+70 -60
View File
@@ -1,7 +1,7 @@
import { useCallback } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { GetRundownCached, isOntimeEvent, OntimeRundownEntry } from 'ontime-types';
import { getPreviousEvent, swapOntimeEvents } from 'ontime-utils';
import { isOntimeEvent, OntimeEvent, OntimeRundownEntry, RundownCached } from 'ontime-types';
import { reorderArray, swapEventData } from 'ontime-utils';
import { RUNDOWN } from '../api/apiConstants';
import { logAxiosError } from '../api/apiUtils';
@@ -34,8 +34,6 @@ export const useEventAction = () => {
* @private
*/
const _addEventMutation = useMutation({
// Mutation finished, failed or successful
// Fetch anyway, just to be sure
mutationFn: requestPostEvent,
onSettled: () => {
queryClient.invalidateQueries({ queryKey: RUNDOWN });
@@ -69,10 +67,12 @@ export const useEventAction = () => {
after: options?.after,
};
const rundown = queryClient.getQueryData<GetRundownCached>(RUNDOWN)?.rundown ?? [];
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- we know this has a value
const rundownData = queryClient.getQueryData<RundownCached>(RUNDOWN)!;
const { rundown } = rundownData;
if (applicationOptions.startTimeIsLastEnd && applicationOptions?.lastEventId) {
const previousEvent = rundown.find((event) => event.id === applicationOptions.lastEventId);
const previousEvent = rundown[applicationOptions.lastEventId];
if (isOntimeEvent(previousEvent)) {
newEvent.timeStart = previousEvent.timeEnd;
newEvent.timeEnd = previousEvent.timeEnd;
@@ -90,7 +90,6 @@ export const useEventAction = () => {
}
try {
// @ts-expect-error -- we know that the object is well formed now
await _addEventMutation.mutateAsync(newEvent);
} catch (error) {
logAxiosError('Failed adding event', error);
@@ -111,18 +110,15 @@ export const useEventAction = () => {
await queryClient.cancelQueries({ queryKey: RUNDOWN });
// Snapshot the previous value
const previousData = queryClient.getQueryData<GetRundownCached>(RUNDOWN);
const previousData = queryClient.getQueryData<RundownCached>(RUNDOWN);
const eventId = newEvent.id;
if (previousData) {
if (previousData && eventId) {
// optimistically update object
const optimisticRundown = [...previousData.rundown];
const index = optimisticRundown.findIndex((event) => event.id === newEvent.id);
if (index > -1) {
// @ts-expect-error -- we expect the event types to match
optimisticRundown[index] = { ...optimisticRundown[index], ...newEvent };
queryClient.setQueryData(RUNDOWN, { rundown: optimisticRundown, revision: -1 });
}
const newRundown = { ...previousData.rundown };
// @ts-expect-error -- we expect the events to be of same type
newRundown[eventId] = { ...newRundown[eventId], ...newEvent };
queryClient.setQueryData(RUNDOWN, { order: previousData.order, rundown: newRundown, revision: -1 });
}
// Return a context with the previous and new events
@@ -161,14 +157,25 @@ export const useEventAction = () => {
const updateTimer = useCallback(
async (eventId: string, field: TimeField, value: string) => {
const getPreviousEnd = (): number => {
const rundown = queryClient.getQueryData<GetRundownCached>(RUNDOWN)?.rundown ?? [];
if (rundown) {
const { previousEvent } = getPreviousEvent(rundown, eventId);
if (previousEvent) {
return previousEvent.timeEnd;
const cachedRundown = queryClient.getQueryData<RundownCached>(RUNDOWN);
if (!cachedRundown?.order || !cachedRundown?.rundown) {
return 0;
}
const index = cachedRundown.order.indexOf(eventId);
if (index === 0) {
return 0;
}
let previousEnd = 0;
for (let i = index - 1; i >= 0; i--) {
const event = cachedRundown.rundown[cachedRundown.order[i]];
if (isOntimeEvent(event)) {
previousEnd = event.timeEnd;
break;
}
}
return 0;
return previousEnd;
};
let newValMillis = 0;
@@ -209,25 +216,26 @@ export const useEventAction = () => {
await queryClient.cancelQueries({ queryKey: RUNDOWN });
// Snapshot the previous value
const previousEvents = queryClient.getQueryData<GetRundownCached>(RUNDOWN);
const previousEvents = queryClient.getQueryData<RundownCached>(RUNDOWN);
if (previousEvents) {
const updatedEvents = previousEvents.rundown.map((event) => {
const isEventEdited = ids.includes(event.id);
const eventIds = new Set(ids);
const newRundown = { ...previousEvents.rundown };
if (isEventEdited && isOntimeEvent(event)) {
return {
...event,
...data,
};
eventIds.forEach((eventId) => {
if (Object.hasOwn(newRundown, eventId)) {
const event = newRundown[eventId];
if (isOntimeEvent(event)) {
newRundown[eventId] = {
...event,
...data,
};
}
}
return event;
});
queryClient.setQueryData(RUNDOWN, { rundown: updatedEvents, revision: -1 });
queryClient.setQueryData(RUNDOWN, { order: previousEvents.order, rundown: newRundown, revision: -1 });
}
// Return a context with the previous and new events
return { previousEvents };
},
@@ -241,7 +249,7 @@ export const useEventAction = () => {
});
const batchUpdateEvents = useCallback(
async (data: Partial<OntimeRundownEntry>, eventIds: string[]) => {
async (data: Partial<OntimeEvent>, eventIds: string[]) => {
try {
await _batchUpdateEventsMutation.mutateAsync({ ids: eventIds, data });
} catch (error) {
@@ -263,20 +271,19 @@ export const useEventAction = () => {
await queryClient.cancelQueries({ queryKey: RUNDOWN });
// Snapshot the previous value
const previousData = queryClient.getQueryData<GetRundownCached>(RUNDOWN);
const previousData = queryClient.getQueryData<RundownCached>(RUNDOWN);
if (previousData) {
// optimistically update object
const optimisticRundown = [...previousData.rundown];
const index = optimisticRundown.findIndex((event) => event.id === eventId);
if (index > -1) {
optimisticRundown.splice(index, 1);
const newOrder = previousData.order.filter((id) => id !== eventId);
const newRundown = { ...previousData.rundown };
delete newRundown[eventId];
queryClient.setQueryData(RUNDOWN, {
rundown: optimisticRundown,
revision: -1,
});
}
queryClient.setQueryData(RUNDOWN, {
order: newOrder,
rundown: newRundown,
revision: -1,
});
}
// Return a context with the previous and new events
@@ -321,10 +328,10 @@ export const useEventAction = () => {
await queryClient.cancelQueries({ queryKey: RUNDOWN });
// Snapshot the previous value
const previousData = queryClient.getQueryData<GetRundownCached>(RUNDOWN);
const previousData = queryClient.getQueryData<RundownCached>(RUNDOWN);
// optimistically update object
queryClient.setQueryData(RUNDOWN, { rundown: [], revision: -1 });
queryClient.setQueryData(RUNDOWN, { rundown: {}, order: [], revision: -1 });
// Return a context with the previous and new events
return { previousData };
@@ -392,15 +399,13 @@ export const useEventAction = () => {
await queryClient.cancelQueries({ queryKey: RUNDOWN });
// Snapshot the previous value
const previousData = queryClient.getQueryData<GetRundownCached>(RUNDOWN);
const previousData = queryClient.getQueryData<RundownCached>(RUNDOWN);
if (previousData) {
// optimistically update object
const optimisticRundown = [...previousData.rundown];
const [reorderedItem] = optimisticRundown.splice(data.from, 1);
optimisticRundown.splice(data.to, 0, reorderedItem);
const newOrder = reorderArray(previousData.order, data.from, data.to);
queryClient.setQueryData(RUNDOWN, { rundown: optimisticRundown, revision: -1 });
queryClient.setQueryData(RUNDOWN, { order: newOrder, rundown: previousData.rundown, revision: -1 });
}
// Return a context with the previous and new events
@@ -450,15 +455,22 @@ export const useEventAction = () => {
await queryClient.cancelQueries({ queryKey: RUNDOWN });
// Snapshot the previous value
const previousData = queryClient.getQueryData<GetRundownCached>(RUNDOWN);
const previousData = queryClient.getQueryData<RundownCached>(RUNDOWN);
if (previousData) {
// optimistically update object
const fromEventIndex = previousData.rundown.findIndex((event) => event.id === from);
const toEventIndex = previousData.rundown.findIndex((event) => event.id === to);
const newRundown = { ...previousData.rundown };
const eventA = previousData.rundown[from];
const eventB = previousData.rundown[to];
const optimisticRundown = swapOntimeEvents(previousData.rundown, fromEventIndex, toEventIndex);
if (!isOntimeEvent(eventA) || !isOntimeEvent(eventB)) {
return;
}
queryClient.setQueryData(RUNDOWN, { rundown: optimisticRundown, revision: -1 });
const { newA, newB } = swapEventData(eventA, eventB);
newRundown[from] = newA;
newRundown[to] = newB;
queryClient.setQueryData(RUNDOWN, { order: previousData.order, rundown: newRundown, revision: -1 });
}
// Return a context with the previous events
@@ -482,8 +494,6 @@ export const useEventAction = () => {
*/
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) {
@@ -1,12 +1,11 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo } from 'react';
import { OntimeRundownEntry, ProjectData } from 'ontime-types';
import Empty from '../../common/components/state/Empty';
import { useEventAction } from '../../common/hooks/useEventAction';
import { useCuesheet } from '../../common/hooks/useSocket';
import useRundown from '../../common/hooks-query/useRundown';
import { useFlatRundown } from '../../common/hooks-query/useRundown';
import useUserFields from '../../common/hooks-query/useUserFields';
import ExportModal, { ExportType } from '../modals/export-modal/ExportModal';
import CuesheetProgress from './cuesheet-progress/CuesheetProgress';
import CuesheetTableHeader from './cuesheet-table-header/CuesheetTableHeader';
@@ -17,13 +16,12 @@ import { makeCSV, makeTable } from './cuesheetUtils';
import styles from './CuesheetWrapper.module.scss';
export default function CuesheetWrapper() {
const { data: rundown } = useRundown();
// TODO: can we use the normalised rundown for the table?
const { data: flatRundown, status: rundownStatus } = useFlatRundown();
const { data: userFields } = useUserFields();
const { updateEvent } = useEventAction();
const featureData = useCuesheet();
const columns = useMemo(() => makeCuesheetColumns(userFields), [userFields]);
const [isModalOpen, setIsModalOpen] = useState(false);
const [headerData, setheaderData] = useState<ProjectData | null>(null);
// Set window title
useEffect(() => {
@@ -32,7 +30,7 @@ export default function CuesheetWrapper() {
const handleUpdate = useCallback(
async (rowIndex: number, accessor: keyof OntimeRundownEntry, payload: unknown) => {
if (!rundown) {
if (!flatRundown || rundownStatus !== 'success') {
return;
}
@@ -41,7 +39,7 @@ export default function CuesheetWrapper() {
}
// check if value is the same
const event = rundown[rowIndex];
const event = flatRundown[rowIndex];
if (!event) {
return;
}
@@ -69,41 +67,21 @@ export default function CuesheetWrapper() {
console.error(error);
}
},
[updateEvent, rundown],
[flatRundown, rundownStatus, updateEvent],
);
const exportHandler = useCallback(
(headerData: ProjectData, exportType: ExportType) => {
if (!headerData || !rundown || !userFields) {
(headerData: ProjectData) => {
if (!userFields || !flatRundown || rundownStatus !== 'success') {
return;
}
const sheetData = makeTable(headerData, flatRundown, userFields);
const csvContent = makeCSV(sheetData);
let fileName = '';
let url = '';
const fileName = 'ontime rundown.csv';
if (exportType === 'json') {
const jsonContent = JSON.stringify({
headerData,
rundown,
userFields,
});
fileName = 'ontime export.json';
const blob = new Blob([jsonContent], { type: 'application/json;charset=utf-8;' });
url = URL.createObjectURL(blob);
} else if (exportType === 'csv') {
const sheetData = makeTable(headerData, rundown, userFields);
const csvContent = makeCSV(sheetData);
fileName = 'ontime export.csv';
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
url = URL.createObjectURL(blob);
} else {
console.error('Invalid export type: ', exportType);
return;
}
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.setAttribute('href', url);
@@ -114,36 +92,23 @@ export default function CuesheetWrapper() {
URL.revokeObjectURL(url);
return;
},
[rundown, userFields],
[flatRundown, rundownStatus, userFields],
);
const onModalClose = (exportType?: ExportType) => {
setIsModalOpen(false);
if (!exportType) {
return;
}
if (headerData) {
exportHandler(headerData, exportType);
}
};
const handleOpenModal = (projectData: ProjectData) => {
setheaderData(projectData);
setIsModalOpen(true);
};
if (!rundown || !userFields) {
if (!userFields || !flatRundown || rundownStatus !== 'success') {
return <Empty text='Loading...' />;
}
return (
<div className={styles.tableWrapper} data-testid='cuesheet'>
<CuesheetTableHeader handleExport={handleOpenModal} featureData={featureData} />
<CuesheetTableHeader handleExport={exportHandler} featureData={featureData} />
<CuesheetProgress />
<Cuesheet data={rundown} columns={columns} handleUpdate={handleUpdate} selectedId={featureData.selectedEventId} />
<ExportModal isOpen={isModalOpen} onClose={onModalClose} />
<Cuesheet
data={flatRundown}
columns={columns}
handleUpdate={handleUpdate}
selectedId={featureData.selectedEventId}
/>
</div>
);
}
@@ -3,25 +3,14 @@
exports[`makeTable() > returns array of arrays with given fields 1`] = `
[
[
"Ontime · Schedule Template",
"Ontime · Rundown export",
],
[
"Project Title",
"",
"Project title: test title",
],
[
"Project Description",
"",
"Project description: test description",
],
[
"Public URL",
"",
],
[
"Backstage URL",
"",
],
[],
[
"Time Start",
"Time End",
@@ -59,7 +59,10 @@ describe('parseField()', () => {
describe('makeTable()', () => {
it('returns array of arrays with given fields', () => {
const headerData = {};
const headerData = {
title: 'test title',
description: 'test description',
};
const tableData = [
{
title: 'test title 1',
@@ -9,7 +9,7 @@ $active-colour: $gray-500;
}
@mixin time {
font-family: "Open Sans Light", $ontime-font-family;
font-family: 'Open Sans Light', $ontime-font-family;
font-size: 2rem;
text-align: center;
}
@@ -22,8 +22,7 @@ $active-colour: $gray-500;
height: max-content;
column-gap: 2rem;
grid-template-areas:
'event playback timer clock actions';
grid-template-areas: 'event playback timer clock actions';
grid-template-columns: 1fr auto auto auto auto;
align-items: center;
justify-items: center;
@@ -91,11 +90,12 @@ $active-colour: $gray-500;
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1.125rem;
color: $label-colour;
height: 100%;
font-size: 1rem;
.actionIcon {
.actionIcon,
.actionText {
cursor: pointer;
&.enabled {
@@ -106,6 +106,10 @@ $active-colour: $gray-500;
color: $active-colour;
}
}
.actionIcon {
font-size: 1.25rem;
}
}
@media (min-width: 1200px) {
@@ -8,6 +8,7 @@ import { Playback, ProjectData } from 'ontime-types';
import PlaybackIcon from '../../../common/components/playback-icon/PlaybackIcon';
import useFullscreen from '../../../common/hooks/useFullscreen';
import useProjectData from '../../../common/hooks-query/useProjectData';
import { cx } from '../../../common/utils/styleUtils';
import { tooltipDelayFast } from '../../../ontimeConfig';
import { useCuesheetSettings } from '../store/CuesheetSettings';
@@ -58,12 +59,18 @@ export default function CuesheetTableHeader({ handleExport, featureData }: Cuesh
<CuesheetTableHeaderTimers />
<div className={style.headerActions}>
<Tooltip openDelay={tooltipDelayFast} label='Toggle follow'>
<span onClick={() => toggleFollow()} className={`${style.actionIcon} ${followSelected ? style.enabled : ''}`}>
<span
onClick={() => toggleFollow()}
className={cx([style.actionIcon, followSelected ? style.enabled : null])}
>
<IoLocate />
</span>
</Tooltip>
<Tooltip openDelay={tooltipDelayFast} label='Toggle settings'>
<span onClick={() => toggleSettings()} className={`${style.actionIcon} ${showSettings ? style.enabled : ''}`}>
<span
onClick={() => toggleSettings()}
className={cx([style.actionIcon, showSettings ? style.enabled : null])}
>
<IoSettingsOutline />
</span>
</Tooltip>
@@ -73,8 +80,8 @@ export default function CuesheetTableHeader({ handleExport, featureData }: Cuesh
</span>
</Tooltip>
<Tooltip openDelay={tooltipDelayFast} label='Export rundown'>
<span className={style.actionIcon} onClick={exportProject}>
Export
<span className={style.actionText} onClick={exportProject}>
Export CSV
</span>
</Tooltip>
</div>
@@ -39,14 +39,9 @@ export const parseField = <T extends OntimeEntryCommonKeys>(field: T, data: unkn
* @return {(string[])[]}
*/
export const makeTable = (headerData: ProjectData, rundown: OntimeRundown, userFields: UserFields): string[][] => {
const data = [
['Ontime · Schedule Template'],
['Project Title', headerData?.title || ''],
['Project Description', headerData?.description || ''],
['Public URL', headerData?.publicUrl || ''],
['Backstage URL', headerData?.backstageUrl || ''],
[],
];
const data = [['Ontime · Rundown export']];
if (headerData.title) data.push([`Project title: ${headerData.title}`]);
if (headerData.description) data.push([`Project description: ${headerData.description}`]);
const fieldOrder: OntimeEntryCommonKeys[] = [
'timeStart',
@@ -5,7 +5,6 @@ import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary'
import AppSettings from '../app-settings/AppSettings';
import { SettingsOptionId, useSettingsStore } from '../app-settings/settingsStore';
import MenuBar from '../menu/MenuBar';
import AboutModal from '../modals/about-modal/AboutModal';
import QuickStart from '../modals/quick-start/QuickStart';
import SheetsModal from '../modals/sheets-modal/SheetsModal';
import UploadModal from '../modals/upload-modal/UploadModal';
@@ -16,7 +15,6 @@ import styles from './Editor.module.scss';
const Rundown = lazy(() => import('../rundown/RundownExport'));
const TimerControl = lazy(() => import('../control/playback/TimerControlExport'));
const MessageControl = lazy(() => import('../control/message/MessageControlExport'));
const IntegrationModal = lazy(() => import('../modals/integration-modal/IntegrationModal'));
const SettingsModal = lazy(() => import('../modals/settings-modal/SettingsModal'));
@@ -35,7 +33,6 @@ export default function Editor() {
onOpen: onIntegrationModalOpen,
onClose: onIntegrationModalClose,
} = useDisclosure();
const { isOpen: isAboutModalOpen, onOpen: onAboutModalOpen, onClose: onAboutModalClose } = useDisclosure();
const { isOpen: isQuickStartOpen, onOpen: onQuickStartOpen, onClose: onQuickStartClose } = useDisclosure();
const { isOpen: isSheetsOpen, onOpen: onSheetsOpen, onClose: onSheetsClose } = useDisclosure();
@@ -52,7 +49,6 @@ export default function Editor() {
<QuickStart onClose={onQuickStartClose} isOpen={isQuickStartOpen} />
<UploadModal onClose={onUploadModalClose} isOpen={isUploadModalOpen} />
<IntegrationModal onClose={onIntegrationModalClose} isOpen={isIntegrationModalOpen} />
<AboutModal onClose={onAboutModalClose} isOpen={isAboutModalOpen} />
<SettingsModal isOpen={isOldSettingsOpen} onClose={onSettingsClose} />
<SheetsModal onClose={onSheetsClose} isOpen={isSheetsOpen} />
</ErrorBoundary>
@@ -66,8 +62,6 @@ export default function Editor() {
onUploadOpen={onUploadModalOpen}
isIntegrationOpen={isIntegrationModalOpen}
onIntegrationOpen={onIntegrationModalOpen}
isAboutOpen={isAboutModalOpen}
onAboutOpen={onAboutModalOpen}
isQuickStartOpen={isQuickStartOpen}
onQuickStartOpen={onQuickStartOpen}
openSettings={handleSettings}
+2 -45
View File
@@ -1,4 +1,4 @@
import { memo, useCallback, useEffect, useState } from 'react';
import { memo, useCallback, useEffect } from 'react';
import { IconButton, MenuButton, Tooltip } from '@chakra-ui/react';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
import { IoCloud } from '@react-icons/all-files/io5/IoCloud';
@@ -6,21 +6,17 @@ import { IoCloudOutline } from '@react-icons/all-files/io5/IoCloudOutline';
import { IoColorWand } from '@react-icons/all-files/io5/IoColorWand';
import { IoExtensionPuzzle } from '@react-icons/all-files/io5/IoExtensionPuzzle';
import { IoExtensionPuzzleOutline } from '@react-icons/all-files/io5/IoExtensionPuzzleOutline';
import { IoHelp } from '@react-icons/all-files/io5/IoHelp';
import { IoOptions } from '@react-icons/all-files/io5/IoOptions';
import { IoPlay } from '@react-icons/all-files/io5/IoPlay';
import { IoPushOutline } from '@react-icons/all-files/io5/IoPushOutline';
import { IoSaveOutline } from '@react-icons/all-files/io5/IoSaveOutline';
import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline';
import { IoSnowOutline } from '@react-icons/all-files/io5/IoSnowOutline';
import { downloadCSV, downloadRundown } from '../../common/api/ontimeApi';
import QuitIconBtn from '../../common/components/buttons/QuitIconBtn';
import TooltipActionBtn from '../../common/components/buttons/TooltipActionBtn';
import useElectronEvent from '../../common/hooks/useElectronEvent';
import { AppMode, useAppMode } from '../../common/stores/appModeStore';
import { cx } from '../../common/utils/styleUtils';
import ExportModal, { ExportType } from '../modals/export-modal/ExportModal';
import RundownMenu from './RundownMenu';
@@ -34,8 +30,6 @@ interface MenuBarProps {
onUploadOpen: () => void;
isIntegrationOpen: boolean;
onIntegrationOpen: () => void;
isAboutOpen: boolean;
onAboutOpen: () => void;
isQuickStartOpen: boolean;
onQuickStartOpen: () => void;
isSheetsOpen: boolean;
@@ -65,8 +59,6 @@ const MenuBar = (props: MenuBarProps) => {
onUploadOpen,
isIntegrationOpen,
onIntegrationOpen,
isAboutOpen,
onAboutOpen,
isQuickStartOpen,
onQuickStartOpen,
openSettings,
@@ -116,22 +108,6 @@ const MenuBar = (props: MenuBarProps) => {
};
}, [handleKeyPress, isElectron]);
const [isModalOpen, setIsModalOpen] = useState(false);
const onModalClose = (exportType?: ExportType) => {
setIsModalOpen(false);
if (!exportType) {
return;
}
if (exportType === 'json') {
downloadRundown();
} else if (exportType === 'csv') {
downloadCSV();
}
};
return (
<div className={style.menu}>
<QuitIconBtn disabled={!isElectron} clickHandler={sendShutdown} />
@@ -154,15 +130,6 @@ const MenuBar = (props: MenuBarProps) => {
tooltip='Import project file'
aria-label='Import project file'
/>
<TooltipActionBtn
{...buttonStyle}
icon={<IoSaveOutline />}
isDisabled={appMode === AppMode.Run}
clickHandler={() => setIsModalOpen(true)}
tooltip='Export project file'
aria-label='Export project file'
/>
<ExportModal onClose={onModalClose} isOpen={isModalOpen} />
<div className={style.gap} />
<RundownMenu>
<Tooltip label='Rundown...'>
@@ -221,17 +188,7 @@ const MenuBar = (props: MenuBarProps) => {
tooltip='Settings'
aria-label='Settings'
/>
<div className={style.gap} />
<TooltipActionBtn
{...buttonStyle}
className={isAboutOpen ? style.open : ''}
icon={<IoHelp />}
clickHandler={onAboutOpen}
tooltip='About'
aria-label='About'
size='sm'
/>
<div className={style.gap} />
<TooltipActionBtn
{...buttonStyle}
className={cx([isSettingsOpen ? style.open : null, style.bottom])}
@@ -1,67 +0,0 @@
import { Modal, ModalBody, ModalCloseButton, ModalContent, ModalHeader, ModalOverlay } from '@chakra-ui/react';
import { version } from '../../../../package.json';
import OntimeLogo from '../../../assets/images/ontime-logo.svg?react';
import { gitbookUrl, githubUrl } from '../../../externals';
import ModalLink from '../ModalLink';
import UpdateChecker from './UpdateChecker';
import styles from '../Modal.module.scss';
interface AboutModalProps {
isOpen: boolean;
onClose: () => void;
}
export default function AboutModal(props: AboutModalProps) {
const { isOpen, onClose } = props;
return (
<Modal
size='sm'
isOpen={isOpen}
onClose={onClose}
closeOnOverlayClick={false}
motionPreset='slideInBottom'
scrollBehavior='inside'
preserveScrollBarGap
variant='ontime-small'
>
<ModalOverlay />
<ModalContent>
<ModalHeader>
About Ontime
<ModalCloseButton />
</ModalHeader>
<ModalBody className={styles.body}>
<div className={styles.twoColumn}>
<OntimeLogo className={styles.logo} />
<div>
<div className={styles.padBottom}>
<span className={styles.sectionTitle}>Ontime</span>
Free Open Source Software for managing rundowns and event timers
<ModalLink href='https://www.getontime.no'>www.getontime.no</ModalLink>
</div>
<div className={styles.padBottom}>
<span className={styles.sectionTitle}>Current version</span>
{`You are currently using Ontime ${version}`}
</div>
<div className={styles.padBottom}>
<span className={styles.sectionTitle}>Docs</span>
<ModalLink href={gitbookUrl}>Read the docs over at GitBook</ModalLink>
</div>
<div>
<span className={styles.sectionTitle}>Github</span>
<ModalLink href={githubUrl}>Follow the project on GitHub</ModalLink>
</div>
<UpdateChecker version={version} />
</div>
</div>
</ModalBody>
</ModalContent>
</Modal>
);
}
@@ -1,72 +0,0 @@
import { useState } from 'react';
import { Button } from '@chakra-ui/react';
import { getLatestVersion, HasUpdate } from '../../../common/api/ontimeApi';
import ModalLink from '../ModalLink';
import styles from '../Modal.module.scss';
interface UpdateCheckerProps {
version: string;
}
type CheckFail = {
error: string;
};
type CheckIsLatest = {
latest: true;
};
type CheckRemote = CheckFail | CheckIsLatest | HasUpdate;
export default function UpdateChecker(props: UpdateCheckerProps) {
const { version } = props;
const [updateMessage, setUpdateMessage] = useState<CheckRemote | null>(null);
const [isFetching, setIsFetching] = useState(false);
/**
* Handles version comparison and returns component with message
*/
const versionCheck = async () => {
setIsFetching(true);
try {
const latest = await getLatestVersion();
if (!latest.version.includes(version)) {
// new version, pass data to component
setUpdateMessage(latest);
} else {
setUpdateMessage({ latest: true });
}
} catch {
setUpdateMessage({ error: 'Error reaching server' });
} finally {
setIsFetching(false);
}
};
const disableButton = Boolean(updateMessage && 'version' in updateMessage);
return (
<div className={styles.updateSection}>
<Button onClick={versionCheck} variant='ontime-filled' isLoading={isFetching} isDisabled={disableButton}>
Check for updates
</Button>
<ResolveUpdateMessage updateMessage={updateMessage} />
</div>
);
}
function ResolveUpdateMessage(props: { updateMessage: CheckRemote | null }) {
const { updateMessage } = props;
if (updateMessage && 'error' in updateMessage) {
return <span className={styles.error}>{updateMessage.error}</span>;
}
if (updateMessage && 'url' in updateMessage) {
return <ModalLink href={updateMessage?.url}>{`New version available: ${updateMessage.version}`}</ModalLink>;
}
return null;
}
@@ -1,7 +0,0 @@
.buttonRow {
justify-content: space-between;
margin-top: $section-spacing;
display: flex;
gap: $section-spacing;
margin: 0 0.25rem;
}
@@ -1,32 +0,0 @@
import { Button, Modal, ModalBody, ModalCloseButton, ModalContent, ModalHeader, ModalOverlay } from '@chakra-ui/react';
import styles from './ExportModal.module.scss';
export type ExportType = 'csv' | 'json';
interface ExportModalProps {
isOpen: boolean;
onClose: (type?: ExportType) => void;
}
export default function ExportModal(props: ExportModalProps) {
const { isOpen, onClose } = props;
return (
<Modal isOpen={isOpen} onClose={onClose} motionPreset='slideInBottom' size='xl' variant='ontime-small'>
<ModalOverlay />
<ModalContent>
<ModalHeader className={styles.modalHeader}>Download options</ModalHeader>
<ModalCloseButton />
<ModalBody className={styles.buttonRow}>
<Button onClick={() => onClose('csv')} variant='ontime-subtle-on-light' width='100%'>
Rundown as CSV
</Button>
<Button onClick={() => onClose('json')} variant='ontime-filled' width='100%'>
Project file
</Button>
</ModalBody>
</ModalContent>
</Modal>
);
}
@@ -150,6 +150,8 @@ export default function UploadModal({ onClose, isOpen }: UploadModalProps) {
setSubmitting(true);
try {
await patchData({ rundown, userFields });
// TODO: broken :(
// we need to normalise the data here
queryClient.setQueryData(RUNDOWN, { rundown, revision: -1 });
queryClient.setQueryData(USERFIELDS, userFields);
await queryClient.invalidateQueries({
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { isOntimeEvent, OntimeEvent, SupportedEvent, UserFields } from 'ontime-types';
import { getFirstEvent, getLastEvent } from 'ontime-utils';
import { getFirstEventNormal, getLastEventNormal } from 'ontime-utils';
import NavigationMenu from '../../common/components/navigation-menu/NavigationMenu';
import Empty from '../../common/components/state/Empty';
@@ -137,8 +137,8 @@ export default function Operator() {
let isPast = Boolean(featureData.selectedEventId);
const hidePast = isStringBoolean(searchParams.get('hidepast'));
const { firstEvent } = getFirstEvent(data);
const { lastEvent } = getLastEvent(data);
const { firstEvent } = getFirstEventNormal(data.rundown, data.order);
const { lastEvent } = getLastEventNormal(data.rundown, data.order);
return (
<div className={style.operatorContainer}>
@@ -163,7 +163,8 @@ export default function Operator() {
)}
<div className={style.operatorEvents} onWheel={handleScroll} onTouchMove={handleScroll} ref={scrollRef}>
{data.map((entry) => {
{data.order.map((eventId) => {
const entry = data.rundown[eventId];
if (isOntimeEvent(entry)) {
const isSelected = featureData.selectedEventId === entry.id;
if (isSelected) {
+44 -37
View File
@@ -1,8 +1,8 @@
import { Fragment, lazy, useCallback, useEffect, useRef, useState } from 'react';
import { closestCenter, DndContext, DragEndEvent, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
import { arrayMove, SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { isOntimeBlock, isOntimeDelay, isOntimeEvent, OntimeRundown, Playback, SupportedEvent } from 'ontime-types';
import { getFirst, getNext, getPrevious } from 'ontime-utils';
import { isOntimeBlock, isOntimeDelay, isOntimeEvent, Playback, RundownCached, SupportedEvent } from 'ontime-types';
import { getFirstNormal, getNextNormal, getPreviousNormal } from 'ontime-utils';
import { useEventAction } from '../../common/hooks/useEventAction';
import useFollowComponent from '../../common/hooks/useFollowComponent';
@@ -19,12 +19,12 @@ import style from './Rundown.module.scss';
const RundownEntry = lazy(() => import('./RundownEntry'));
interface RundownProps {
entries: OntimeRundown;
data: RundownCached;
}
export default function Rundown(props: RundownProps) {
const { entries } = props;
const [statefulEntries, setStatefulEntries] = useState(entries);
export default function Rundown({ data }: RundownProps) {
const { order, rundown } = data;
const [statefulEntries, setStatefulEntries] = useState(order);
const featureData = useRundownEditor();
const { addEvent, reorderEvent } = useEventAction();
@@ -56,7 +56,7 @@ export default function Rundown(props: RundownProps) {
}
if (type === 'clone') {
const cursorEvent = entries.find((event) => event.id === cursor);
const cursorEvent = rundown[cursor];
if (cursorEvent?.type === SupportedEvent.Event) {
const newEvent = cloneEvent(cursorEvent, cursorEvent.id);
addEvent(newEvent);
@@ -76,7 +76,7 @@ export default function Rundown(props: RundownProps) {
addEvent({ type }, { after: cursor });
}
},
[addEvent, defaultPublic, entries, startTimeIsLastEnd],
[addEvent, rundown, defaultPublic, startTimeIsLastEnd],
);
// Handle keyboard shortcuts
@@ -91,21 +91,23 @@ export default function Rundown(props: RundownProps) {
if (modKeysAlt) {
switch (event.code) {
case 'ArrowDown': {
if (entries.length < 1) {
if (order.length < 1) {
return;
}
const nextEvent = cursor == null ? getFirst(entries) : getNext(entries, cursor)?.nextEvent;
const nextEvent =
cursor == null ? getFirstNormal(rundown, order) : getNextNormal(rundown, order, cursor)?.nextEvent;
if (nextEvent) {
// moveCursorTo(nextEvent.id, nextEvent.type === SupportedEvent.Event);
}
break;
}
case 'ArrowUp': {
if (entries.length < 1) {
if (order.length < 1) {
return;
}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- we check for this before
const previousEvent = cursor == null ? getFirst(entries) : getPrevious(entries, cursor).previousEvent;
const previousEvent =
cursor == null ? getFirstNormal(rundown, order) : getPreviousNormal(rundown, order, cursor).previousEvent;
if (previousEvent) {
// moveCursorTo(previousEvent.id, previousEvent.type === SupportedEvent.Event);
}
@@ -133,32 +135,30 @@ export default function Rundown(props: RundownProps) {
}
}
} else if (modKeysCtrlAlt) {
if (entries.length < 2 || cursor == null) {
if (order.length < 2 || cursor == null) {
return;
}
if (event.code == 'ArrowDown') {
const { nextEvent, nextIndex } = getNext(entries, cursor);
const { nextEvent, nextIndex } = getNextNormal(rundown, order, cursor);
if (nextEvent && nextIndex !== null) {
reorderEvent(cursor, nextIndex - 1, nextIndex);
}
} else if (event.code == 'ArrowUp') {
const { previousEvent, previousIndex } = getPrevious(entries, cursor);
const { previousEvent, previousIndex } = getPreviousNormal(rundown, order, cursor);
if (previousEvent && previousIndex !== null) {
reorderEvent(cursor, previousIndex + 1, previousIndex);
}
}
}
},
[cursor, entries, insertAtCursor, reorderEvent],
[cursor, insertAtCursor, order, rundown, reorderEvent],
);
// we copy the state from the store here
// to workaround async updates on the drag mutations
useEffect(() => {
if (entries) {
setStatefulEntries(entries);
}
}, [entries]);
setStatefulEntries(order);
}, [order]);
// listen to keys
useEffect(() => {
@@ -193,7 +193,7 @@ export default function Rundown(props: RundownProps) {
}
};
if (statefulEntries?.length < 1) {
if (statefulEntries.length < 1) {
return <RundownEmpty handleAddNew={() => insertAtCursor(SupportedEvent.Event, null)} />;
}
@@ -208,39 +208,46 @@ export default function Rundown(props: RundownProps) {
<DndContext onDragEnd={handleOnDragEnd} sensors={sensors} collisionDetection={closestCenter}>
<SortableContext items={statefulEntries} strategy={verticalListSortingStrategy}>
<div className={style.list}>
{statefulEntries.map((entry, index) => {
{statefulEntries.map((eventId, index) => {
// we iterate through a stateful copy of order to make the operations smoother
// this means that this can be out of sync with order until the useEffect runs
// instead of writing all the logic guards, we simply short circuit rendering here
const event = rundown[eventId];
if (!event) {
return null;
}
if (index === 0) {
eventIndex = 0;
}
let isFirstEvent = false;
if (isOntimeEvent(entry)) {
if (isOntimeEvent(event)) {
isFirstEvent = eventIndex === 0;
// event indexes are 1 based in frontend
eventIndex++;
if (!isFirstEvent) {
previousEnd = thisEnd;
}
thisEnd = entry.timeEnd;
previousEventId = entry.id;
thisEnd = event.timeEnd;
previousEventId = event.id;
}
const isLast = index === entries.length - 1;
const isSelected = featureData?.selectedEventId === entry.id;
const isNext = featureData?.nextEventId === entry.id;
const hasCursor = entry.id === cursor;
const isLast = index === order.length - 1;
const isSelected = featureData?.selectedEventId === event.id;
const isNext = featureData?.nextEventId === event.id;
const hasCursor = event.id === cursor;
if (isSelected) {
isPast = false;
}
return (
<Fragment key={entry.id}>
<Fragment key={event.id}>
<div className={style.entryWrapper} data-testid={`entry-${eventIndex}`}>
{entry.type === SupportedEvent.Event && <div className={style.entryIndex}>{eventIndex}</div>}
<div className={style.entry} key={entry.id} ref={hasCursor ? cursorRef : undefined}>
{isOntimeEvent(event) && <div className={style.entryIndex}>{eventIndex}</div>}
<div className={style.entry} key={event.id} ref={hasCursor ? cursorRef : undefined}>
<RundownEntry
type={entry.type}
type={event.type}
isPast={isPast}
eventIndex={eventIndex}
data={entry}
data={event}
selected={isSelected}
hasCursor={hasCursor}
next={isNext}
@@ -254,10 +261,10 @@ export default function Rundown(props: RundownProps) {
{((showQuickEntry && hasCursor) || isLast) && (
<QuickAddBlock
showKbd={hasCursor}
eventId={entry.id}
eventId={event.id}
previousEventId={previousEventId}
disableAddDelay={isOntimeDelay(entry)}
disableAddBlock={isOntimeBlock(entry)}
disableAddDelay={isOntimeDelay(event)}
disableAddBlock={isOntimeBlock(event)}
/>
)}
</Fragment>
@@ -1,18 +1,8 @@
import { useCallback } from 'react';
import {
GetRundownCached,
isOntimeEvent,
MaybeNumber,
OntimeEvent,
OntimeRundownEntry,
Playback,
SupportedEvent,
} from 'ontime-types';
import { MaybeNumber, OntimeEvent, OntimeRundownEntry, Playback, SupportedEvent } from 'ontime-types';
import { RUNDOWN } from '../../common/api/apiConstants';
import { useEventAction } from '../../common/hooks/useEventAction';
import useMemoisedFn from '../../common/hooks/useMemoisedFn';
import { ontimeQueryClient } from '../../common/queryClient';
import { useAppMode } from '../../common/stores/appModeStore';
import { useEditorSettings } from '../../common/stores/editorSettings';
import { useEmitLog } from '../../common/stores/logger';
@@ -103,28 +93,20 @@ export default function RundownEntry(props: RundownEntryProps) {
case 'update': {
// Handles and filters update requests
const { field, value } = payload as FieldValue;
if (field === undefined || value === undefined) {
return;
}
const newData: Partial<OntimeEvent> = { id: data.id };
// if selected events are more than one
// we need to bulk edit
if (selectedEvents.size > 1) {
const changes: Partial<OntimeEvent> = { [field]: value };
const rundown = ontimeQueryClient.getQueryData<GetRundownCached>(RUNDOWN)?.rundown ?? [];
const idsOfRundownEvents = rundown.filter(isOntimeEvent).map((event) => event.id);
const eventIds = [...selectedEvents.keys()];
// check every selected event id to see if they match rundown event ids
const areIdsValid = eventIds.every((eventId) => idsOfRundownEvents.includes(eventId));
if (!areIdsValid) {
return;
}
batchUpdateEvents(changes, eventIds);
batchUpdateEvents(changes, Array.from(selectedEvents));
return clearSelectedEvents();
}
if (field in data) {
// @ts-expect-error not sure how to type this
// @ts-expect-error -- not sure how to type this
newData[field] = value;
return updateEvent(newData);
}
@@ -10,7 +10,7 @@ export default function RundownWrapper() {
return (
<div className={styles.rundownWrapper}>
{status === 'success' && data ? <Rundown entries={data} /> : <Empty text='Connecting to server' />}
{status === 'success' && data ? <Rundown data={data} /> : <Empty text='Connecting to server' />}
</div>
);
}
@@ -22,3 +22,10 @@
@include drag-style;
grid-area: drag;
}
.actionButtons {
grid-area: btns;
display: flex;
align-items: center;
gap: 0.5rem;
}
@@ -1,5 +1,5 @@
import { useEffect, useRef } from 'react';
import { Button, HStack } from '@chakra-ui/react';
import { Button } from '@chakra-ui/react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { IoCheckmark } from '@react-icons/all-files/io5/IoCheckmark';
@@ -72,7 +72,7 @@ export default function DelayBlock(props: DelayBlockProps) {
<IoReorderTwo />
</span>
<DelayInput eventId={data.id} duration={data.duration} />
<HStack spacing='8px' className={style.actionOverlay}>
<div className={style.actionButtons}>
<Button onClick={applyDelayHandler} size='sm' leftIcon={<IoCheckmark />} variant='ontime-subtle-white'>
Apply
</Button>
@@ -80,7 +80,7 @@ export default function DelayBlock(props: DelayBlockProps) {
Cancel
</Button>
<BlockActionMenu enableDelete actionHandler={actionHandler} />
</HStack>
</div>
</div>
);
}
@@ -10,31 +10,17 @@ import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical';
import { EndAction, MaybeNumber, OntimeEvent, Playback, TimerType } from 'ontime-types';
import { useContextMenu } from '../../../common/hooks/useContextMenu';
import useRundown from '../../../common/hooks-query/useRundown';
import copyToClipboard from '../../../common/utils/copyToClipboard';
import { isMacOS } from '../../../common/utils/deviceUtils';
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
import type { EventItemActions } from '../RundownEntry';
import { useEventIdSwapping } from '../useEventIdSwapping';
import { EditMode, useEventSelection } from '../useEventSelection';
import { getSelectionMode, useEventSelection } from '../useEventSelection';
import EventBlockInner from './EventBlockInner';
import RundownIndicators from './RundownIndicators';
import style from './EventBlock.module.scss';
const getEditMode = (event: MouseEvent): EditMode => {
if ((isMacOS() && event.metaKey) || event.ctrlKey) {
return 'ctrl';
}
if (event.shiftKey) {
return 'shift';
}
return 'click';
};
interface EventBlockProps {
cue: string;
timeStart: number;
@@ -95,7 +81,6 @@ export default function EventBlock(props: EventBlockProps) {
} = props;
const { selectedEventId, setSelectedEventId, clearSelectedEventId } = useEventIdSwapping();
const { selectedEvents, setSelectedEvents } = useEventSelection();
const { data: rundown = [] } = useRundown();
const handleRef = useRef<null | HTMLSpanElement>(null);
const [isVisible, setIsVisible] = useState(false);
@@ -235,8 +220,10 @@ export default function EventBlock(props: EventBlockProps) {
return;
}
const editMode = getEditMode(event);
return setSelectedEvents({ id: eventId, index: eventIndex, rundown, editMode });
// UI indexes are 1 based
const index = eventIndex - 1;
const editMode = getSelectionMode(event);
return setSelectedEvents({ id: eventId, index, selectMode: editMode });
// moveCursorTo(eventId, true);
};
@@ -36,24 +36,30 @@ export type EditorUpdateFields =
export default function EventEditor() {
const selectedEvents = useEventSelection((state) => state.selectedEvents);
const { data } = useRundown();
const { order, rundown } = data;
const { updateEvent } = useEventAction();
const [event, setEvent] = useState<OntimeEvent | null>(null);
useEffect(() => {
if (!data) {
if (order.length === 0) {
setEvent(null);
return;
}
const event = data.find((event) => selectedEvents.has(event.id));
const selectedEventId = order.find((eventId) => selectedEvents.has(eventId));
if (!selectedEventId) {
setEvent(null);
return;
}
const event = rundown[selectedEventId];
if (event && isOntimeEvent(event)) {
setEvent(event);
} else {
setEvent(null);
}
}, [data, selectedEvents]);
}, [order, rundown, selectedEvents]);
const handleSubmit = useCallback(
(field: EditorUpdateFields, value: string) => {
@@ -1,12 +1,17 @@
import { isOntimeEvent, OntimeRundown } from 'ontime-types';
import { MouseEvent } from 'react';
import { isOntimeEvent, OntimeEvent, RundownCached } from 'ontime-types';
import { create } from 'zustand';
export type EditMode = 'shift' | 'click' | 'ctrl';
import { RUNDOWN } from '../../common/api/apiConstants';
import { ontimeQueryClient } from '../../common/queryClient';
import { isMacOS } from '../../common/utils/deviceUtils';
export type SelectionMode = 'shift' | 'click' | 'ctrl';
interface EventSelectionStore {
selectedEvents: Set<string>;
anchoredIndex: number | null;
setSelectedEvents: (selectionArgs: { id: string; index: number; rundown: OntimeRundown; editMode: EditMode }) => void;
setSelectedEvents: (selectionArgs: { id: string; index: number; selectMode: SelectionMode }) => void;
clearSelectedEvents: () => void;
}
@@ -14,72 +19,79 @@ export const useEventSelection = create<EventSelectionStore>()((set, get) => ({
selectedEvents: new Set(),
anchoredIndex: null,
setSelectedEvents: (selectionArgs) => {
const { id, index: eventIndex, rundown, editMode } = selectionArgs;
// event indexes are not 0 based
const index = eventIndex - 1;
const { id, index, selectMode } = selectionArgs;
const { selectedEvents, anchoredIndex } = get();
if (editMode === 'click') {
// on click, we replace selection with event
if (selectMode === 'click') {
return set({ selectedEvents: new Set([id]), anchoredIndex: index });
}
if (editMode === 'ctrl') {
if (selectedEvents.has(id)) {
const eventIds = rundown.reduce(
(newRundown, event, i) => {
if (isOntimeEvent(event) && selectedEvents.has(id)) {
return newRundown.concat({ id: event.id, index: i });
}
return newRundown;
},
[] as { id: string; index: number }[],
);
// find the next available higher index
// if unavailable, then grab the last index of events
const newAnchoredIndex = eventIds.find(({ index: eventIndex }) => eventIndex > index) ?? eventIds.at(-1);
selectedEvents.delete(id);
// on ctrl + click, we toggle the selection of that event
if (selectMode === 'ctrl') {
const rundownData = ontimeQueryClient.getQueryData<RundownCached>(RUNDOWN);
if (!rundownData) return;
// if it doesnt exist, simply add to the list and set an anchor
if (!selectedEvents.has(id)) {
return set({
selectedEvents,
anchoredIndex: newAnchoredIndex?.index ?? 0,
});
}
return set({
selectedEvents: selectedEvents.add(id),
anchoredIndex: index,
});
}
if (editMode === 'shift') {
const eventIds = rundown.filter(isOntimeEvent);
if (anchoredIndex === null) {
const eventsUntilIndex = eventIds.slice(0, eventIndex).map((event) => event.id);
return set({ selectedEvents: new Set(eventsUntilIndex), anchoredIndex: index });
}
if (anchoredIndex > index) {
const eventsFromIndex = eventIds.slice(index, anchoredIndex + 1).map((event) => event.id);
return set({
selectedEvents: new Set([...selectedEvents, ...eventsFromIndex]),
selectedEvents: selectedEvents.add(id),
anchoredIndex: index,
});
}
const eventsUntilIndex = eventIds.slice(anchoredIndex, eventIndex).map((event) => event.id);
// if event is already selected, we remove it from selection
// and set the anchor to the event after
selectedEvents.delete(id);
const nextIndex = rundownData.order.findIndex(
(eventId, i) => i > index && isOntimeEvent(rundownData.rundown[eventId]) && selectedEvents.has(eventId),
);
// if we didnt find anything after, set the anchor to the last event
return set({
selectedEvents,
anchoredIndex: nextIndex < 0 ? rundownData.order.length - 1 : nextIndex,
});
}
// on shift + click, we select a range of events up to the clicked event
if (selectMode === 'shift') {
const rundownData = ontimeQueryClient.getQueryData<RundownCached>(RUNDOWN);
if (!rundownData) return;
// get list of rundown with only ontime events
const events: OntimeEvent[] = [];
rundownData.order.forEach((eventId) => {
const event = rundownData.rundown[eventId];
if (isOntimeEvent(event)) {
events.push(event);
}
});
const start = anchoredIndex === null ? 0 : Math.min(anchoredIndex, index);
const end = anchoredIndex === null ? index : Math.max(anchoredIndex, index + 1);
// create new set with range of ids from start to end
const selectedEventIds = events.slice(start, end).map((event) => event.id);
return set({
selectedEvents: new Set([...selectedEvents, ...eventsUntilIndex]),
selectedEvents: new Set([...selectedEvents, ...selectedEventIds]),
anchoredIndex: index,
});
}
},
clearSelectedEvents: () => set({ selectedEvents: new Set() }),
}));
export function getSelectionMode(event: MouseEvent): SelectionMode {
if ((isMacOS() && event.metaKey) || event.ctrlKey) {
return 'ctrl';
}
if (event.shiftKey) {
return 'shift';
}
return 'click';
}
@@ -4,7 +4,7 @@ import { Message, OntimeEvent, ProjectData, Settings, SupportedEvent, TimerMessa
import { useStore } from 'zustand';
import useProjectData from '../../common/hooks-query/useProjectData';
import useRundown from '../../common/hooks-query/useRundown';
import { useFlatRundown } from '../../common/hooks-query/useRundown';
import useSettings from '../../common/hooks-query/useSettings';
import useViewSettings from '../../common/hooks-query/useViewSettings';
import { runtimeStore } from '../../common/stores/runtime';
@@ -42,7 +42,7 @@ const withData = <P extends WithDataProps>(Component: ComponentType<P>) => {
const isMirrored = useViewOptionsStore((state) => state.mirror);
// HTTP API data
const { data: rundownData } = useRundown();
const { data: rundownData } = useFlatRundown();
const { data: project } = useProjectData();
const { data: viewSettings } = useViewSettings();
const { data: settings } = useSettings();