+
+
+ {isOntimeCloud ? (
+
+ ) : (
+
+
+
+ )}
+ {isLockedToView ? (
+
+ ) : (
+
+
+
+ )}
-
-
- setValue('lock', checked)}
- />
-
-
-
- setValue('authenticate', checked)}
- />
-
-
-
-
-
-
-
-
-
{url}
+ {watch('path') === OntimeView.Cuesheet && (
+ <>
+
+ ).alias?.message}
+ />
+
+
+
+ >
+ )}
+
+
+
+ setValue('lockNav', checked, { shouldDirty: true })}
+ />
+
+ {watch('path') !== OntimeView.Cuesheet && (
+
+
+ setValue('lockConfig', checked, { shouldDirty: true })}
+ />
+
+ )}
+
+
+ setValue('authenticate', checked, { shouldDirty: true })}
+ />
+
+
+
{errors.root?.message}
+
+
+
+
+
+ Share this link
+
+
+ {url}
-
-
+
Copy link
+
+
);
}
diff --git a/apps/client/src/features/sharing/GenerateLinkFormExport.tsx b/apps/client/src/features/sharing/GenerateLinkFormExport.tsx
index 556645bed..569587317 100644
--- a/apps/client/src/features/sharing/GenerateLinkFormExport.tsx
+++ b/apps/client/src/features/sharing/GenerateLinkFormExport.tsx
@@ -1,4 +1,5 @@
import { useMemo } from 'react';
+import { OntimeView } from 'ontime-types';
import useInfo from '../../common/hooks-query/useInfo';
import useUrlPresets from '../../common/hooks-query/useUrlPresets';
@@ -6,37 +7,42 @@ import useUrlPresets from '../../common/hooks-query/useUrlPresets';
import GenerateLinkForm from './GenerateLinkForm';
interface GenerateLinkFormExportProps {
- lockedPath?: { value: string; label: string };
+ lockedPath?: { value: OntimeView; label: string };
}
export default function GenerateLinkFormExport({ lockedPath }: GenerateLinkFormExportProps) {
const { data: infoData } = useInfo();
const { data: urlPresetData } = useUrlPresets({ skip: lockedPath === undefined });
- const hostOptions = useMemo(
- () =>
- infoData.networkInterfaces.map((nif) => ({
- value: nif.address,
- label: `${nif.name} - ${nif.address}`,
- })),
- [infoData.networkInterfaces],
- );
+ const hostOptions = useMemo(() => {
+ return infoData.networkInterfaces.map((nif) => ({
+ value: nif.address,
+ label: `${nif.name} - ${nif.address}`,
+ }));
+ }, [infoData.networkInterfaces]);
const pathOptions = useMemo(() => {
if (lockedPath) {
return [{ value: lockedPath.value, label: lockedPath.label }];
}
return [
- { value: 'timer', label: 'Timer' },
- { value: 'cuesheet', label: 'Cuesheet' },
- { value: 'op', label: 'Operator' },
+ { value: OntimeView.Timer, label: 'Timer' },
+ { value: OntimeView.Cuesheet, label: 'Cuesheet' },
+ { value: OntimeView.Operator, label: 'Operator' },
{ value: '', label: 'Companion' },
...urlPresetData.map((preset) => ({
- value: preset.alias,
+ value: `preset-${preset.alias}`,
label: `URL Preset: ${preset.alias}`,
})),
];
}, [lockedPath, urlPresetData]);
- return
;
+ return (
+
+ );
}
diff --git a/apps/client/src/features/sharing/composite/CuesheetLinkOptions.module.scss b/apps/client/src/features/sharing/composite/CuesheetLinkOptions.module.scss
new file mode 100644
index 000000000..4de828e96
--- /dev/null
+++ b/apps/client/src/features/sharing/composite/CuesheetLinkOptions.module.scss
@@ -0,0 +1,18 @@
+.twoCols {
+ display: grid;
+ grid-template-columns: max-content max-content;
+ column-gap: 3rem;
+}
+
+.grid {
+ display: grid;
+ grid-template-columns: repeat(3, max-content);
+ column-gap: 1rem;
+ row-gap: 0.5rem;
+ align-content: start;
+}
+
+.inline {
+ display: flex;
+ gap: 0.5rem;
+}
\ No newline at end of file
diff --git a/apps/client/src/features/sharing/composite/CuesheetLinkOptions.tsx b/apps/client/src/features/sharing/composite/CuesheetLinkOptions.tsx
new file mode 100644
index 000000000..ac83f34e2
--- /dev/null
+++ b/apps/client/src/features/sharing/composite/CuesheetLinkOptions.tsx
@@ -0,0 +1,187 @@
+import { Fragment, RefObject, useMemo, useState } from 'react';
+
+import RadioGroup from '../../../common/components/radio-group/RadioGroup';
+import Switch from '../../../common/components/switch/Switch';
+import useCustomFields from '../../../common/hooks-query/useCustomFields';
+import { cuesheetDefaultColumns, makeCuesheetCustomColumns } from '../../../views/cuesheet/cuesheet.options';
+import * as Panel from '../../app-settings/panel-utils/PanelUtils';
+
+import style from './CuesheetLinkOptions.module.scss';
+
+type AccessMode = 'full' | 'custom';
+
+interface CuesheetLinkOptionsProps {
+ readRef?: RefObject
;
+ writeRef?: RefObject;
+}
+
+export default function CuesheetLinkOptions({ readRef, writeRef }: CuesheetLinkOptionsProps) {
+ const { data } = useCustomFields();
+ const customFieldColumns = useMemo(() => makeCuesheetCustomColumns(data), [data]);
+
+ const [readPermissions, setReadPermissions] = useState('full');
+ const [writePermissions, setWritePermissions] = useState('full');
+
+ const [readSwitches, setReadSwitches] = useState>(() => {
+ const initialState: Record = {};
+ [...cuesheetDefaultColumns, ...customFieldColumns].forEach((column) => {
+ initialState[column.value] = true;
+ });
+ return initialState;
+ });
+
+ const [writeSwitches, setWriteSwitches] = useState>(() => {
+ const initialState: Record = {};
+ [...cuesheetDefaultColumns, ...customFieldColumns].forEach((column) => {
+ initialState[column.value] = true;
+ });
+ return initialState;
+ });
+
+ const handleReadModeChange = (value: AccessMode) => {
+ setReadPermissions(value);
+
+ setReadSwitches((prevReadSwitches) => {
+ const updatedReadSwitches = { ...prevReadSwitches };
+ Object.keys(updatedReadSwitches).forEach((key) => {
+ updatedReadSwitches[key] = true;
+ });
+ return updatedReadSwitches;
+ });
+ };
+
+ const handleWriteModeChange = (value: AccessMode) => {
+ if (value === 'full') {
+ setReadPermissions('full');
+ }
+ setWritePermissions(value);
+
+ setReadSwitches((prevReadSwitches) => {
+ const updatedReadSwitches = { ...prevReadSwitches };
+ setWriteSwitches((prevWriteSwitches) => {
+ const updatedWriteSwitches = { ...prevWriteSwitches };
+ [...cuesheetDefaultColumns, ...customFieldColumns].forEach((column) => {
+ updatedReadSwitches[column.value] = true;
+ updatedWriteSwitches[column.value] = true;
+ });
+ return updatedWriteSwitches;
+ });
+ return updatedReadSwitches;
+ });
+ };
+
+ const handleSwitchChange = (key: string, type: 'read' | 'write', value: boolean) => {
+ if (type === 'read') {
+ setReadSwitches((prevReadSwitches) => {
+ const updatedReadSwitches = { ...prevReadSwitches, [key]: value };
+ return updatedReadSwitches;
+ });
+ } else {
+ setWriteSwitches((prevWriteSwitches) => {
+ const updatedWriteSwitches = { ...prevWriteSwitches, [key]: value };
+ return updatedWriteSwitches;
+ });
+ }
+ };
+
+ const getReadPermissions = () => {
+ if (readPermissions === 'full' || writePermissions === 'full') {
+ return 'full';
+ }
+
+ return Object.entries(readSwitches)
+ .filter(([_, value]) => value)
+ .map(([key]) => key)
+ .join(',');
+ };
+
+ const getWritePermissions = () => {
+ if (writePermissions === 'full') {
+ return 'full';
+ }
+
+ return Object.entries(writeSwitches)
+ .filter(([_, value]) => value)
+ .map(([key]) => key)
+ .join(',');
+ };
+
+ return (
+
+
+
+
+
+
+
Ontime columns
+
Read
+
Write
+ {cuesheetDefaultColumns.map((column) => (
+
+ {column.label}
+ handleSwitchChange(column.value, 'read', value)}
+ disabled={readPermissions === 'full' || writePermissions === 'full'}
+ data-testid={`read-${column.value}`}
+ />
+ handleSwitchChange(column.value, 'write', value)}
+ disabled={writePermissions === 'full'}
+ data-testid={`write-${column.value}`}
+ />
+
+ ))}
+
+ {customFieldColumns.length > 0 && (
+
+
Custom fields
+
Read
+
Write
+ {customFieldColumns.map((column) => (
+
+ {column.label}
+ handleSwitchChange(column.value, 'read', value)}
+ disabled={readPermissions === 'full' || writePermissions === 'full'}
+ data-testid={`read-${column.value}`}
+ />
+ handleSwitchChange(column.value, 'write', value)}
+ disabled={writePermissions === 'full'}
+ data-testid={`write-${column.value}`}
+ />
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/apps/client/src/views/backstage/backstage.options.ts b/apps/client/src/views/backstage/backstage.options.ts
index d50103de9..7fd2a708a 100644
--- a/apps/client/src/views/backstage/backstage.options.ts
+++ b/apps/client/src/views/backstage/backstage.options.ts
@@ -1,4 +1,4 @@
-import { useMemo } from 'react';
+import { use, useMemo } from 'react';
import { useSearchParams } from 'react-router';
import { CustomFields, OntimeEvent, ProjectData } from 'ontime-types';
@@ -9,6 +9,7 @@ import {
makeOptionsFromCustomFields,
makeProjectDataOptions,
} from '../../common/components/view-params-editor/viewParams.utils';
+import { PresetContext } from '../../common/context/PresetContext';
import { scheduleOptions } from '../common/schedule/schedule.options';
export const getBackstageOptions = (
@@ -62,11 +63,13 @@ type BackstageOptions = {
* Utility extract the view options from URL Params
* the names and fallback are manually matched with timerOptions
*/
-function getOptionsFromParams(searchParams: URLSearchParams): BackstageOptions {
- // we manually make an object that matches the key above
+function getOptionsFromParams(searchParams: URLSearchParams, defaultValues?: URLSearchParams): BackstageOptions {
+ // Helper to get value from either source, prioritizing defaultValues
+ const getValue = (key: string) => defaultValues?.get(key) ?? searchParams.get(key);
+
return {
- secondarySource: searchParams.get('secondary-src') as keyof OntimeEvent | null,
- extraInfo: searchParams.get('extra-info'),
+ secondarySource: getValue('secondary-src') as keyof OntimeEvent | null,
+ extraInfo: getValue('extra-info'),
};
}
@@ -75,6 +78,12 @@ function getOptionsFromParams(searchParams: URLSearchParams): BackstageOptions {
*/
export function useBackstageOptions(): BackstageOptions {
const [searchParams] = useSearchParams();
- const options = useMemo(() => getOptionsFromParams(searchParams), [searchParams]);
+ const maybePreset = use(PresetContext);
+
+ const options = useMemo(() => {
+ const defaultValues = maybePreset ? new URLSearchParams(maybePreset.search) : undefined;
+ return getOptionsFromParams(searchParams, defaultValues);
+ }, [maybePreset, searchParams]);
+
return options;
}
diff --git a/apps/client/src/views/common/not-found/NotFound.module.scss b/apps/client/src/views/common/not-found/NotFound.module.scss
new file mode 100644
index 000000000..eaa16a671
--- /dev/null
+++ b/apps/client/src/views/common/not-found/NotFound.module.scss
@@ -0,0 +1,11 @@
+@use '@/theme/viewerDefs' as *;
+
+.notFound {
+ display: grid;
+ place-items: center;
+ background-color: var(--background-color-override, $viewer-background-color);
+ margin-top: 10vh;
+ color: $ui-white;
+ justify-content: center;
+ text-align: center;
+}
diff --git a/apps/client/src/views/common/not-found/NotFound.tsx b/apps/client/src/views/common/not-found/NotFound.tsx
new file mode 100644
index 000000000..2e9f3221a
--- /dev/null
+++ b/apps/client/src/views/common/not-found/NotFound.tsx
@@ -0,0 +1,19 @@
+import EmptyImage from '../../../assets/images/empty.svg?react';
+
+import style from './NotFound.module.scss';
+
+export default function NotFound() {
+ return (
+
+
+
Not found
+
+ The page you are after was not found.
+
+ It may have moved or your URL may be incorrect.
+
+ Double check the URL and try again.
+
+
+ );
+}
diff --git a/apps/client/src/views/countdown/countdown.options.ts b/apps/client/src/views/countdown/countdown.options.ts
index 93d590ef5..7cd1bf26d 100644
--- a/apps/client/src/views/countdown/countdown.options.ts
+++ b/apps/client/src/views/countdown/countdown.options.ts
@@ -1,4 +1,4 @@
-import { useMemo } from 'react';
+import { use, useMemo } from 'react';
import { useSearchParams } from 'react-router';
import { CustomFields, EntryId, OntimeEvent } from 'ontime-types';
@@ -6,6 +6,7 @@ import { getTimeOption } from '../../common/components/view-params-editor/common
import { OptionTitle } from '../../common/components/view-params-editor/constants';
import { ViewOption } from '../../common/components/view-params-editor/viewParams.types';
import { makeOptionsFromCustomFields } from '../../common/components/view-params-editor/viewParams.utils';
+import { PresetContext } from '../../common/context/PresetContext';
import { isStringBoolean } from '../../features/viewers/common/viewUtils';
export const getCountdownOptions = (
@@ -69,12 +70,22 @@ type CountdownOptions = {
* Utility extract the view options from URL Params
* the names and fallback are manually matched with timerOptions
*/
-function getOptionsFromParams(searchParams: URLSearchParams): CountdownOptions {
- // we manually make an object that matches the key above
+function getOptionsFromParams(searchParams: URLSearchParams, defaultValues?: URLSearchParams): CountdownOptions {
+ // Helper to get single value from either source, prioritizing defaultValues
+ const getValue = (key: string) => defaultValues?.get(key) ?? searchParams.get(key);
+
+ // Helper to get array values from either source
+ const getArrayValues = (key: string): EntryId[] => {
+ if (defaultValues?.has(key)) {
+ return defaultValues.getAll(key) as EntryId[];
+ }
+ return searchParams.getAll(key) as EntryId[];
+ };
+
return {
- subscriptions: searchParams.getAll('sub') as EntryId[],
- secondarySource: searchParams.get('secondary-src') as keyof OntimeEvent | null,
- showExpected: isStringBoolean(searchParams.get('showExpected')),
+ subscriptions: getArrayValues('sub'),
+ secondarySource: getValue('secondary-src') as keyof OntimeEvent | null,
+ showExpected: isStringBoolean(getValue('showExpected')),
};
}
@@ -83,6 +94,12 @@ function getOptionsFromParams(searchParams: URLSearchParams): CountdownOptions {
*/
export function useCountdownOptions(): CountdownOptions {
const [searchParams] = useSearchParams();
- const options = useMemo(() => getOptionsFromParams(searchParams), [searchParams]);
+ const maybePreset = use(PresetContext);
+
+ const options = useMemo(() => {
+ const defaultValues = maybePreset ? new URLSearchParams(maybePreset.search) : undefined;
+ return getOptionsFromParams(searchParams, defaultValues);
+ }, [maybePreset, searchParams]);
+
return options;
}
diff --git a/apps/client/src/views/cuesheet/CuesheetPage.tsx b/apps/client/src/views/cuesheet/CuesheetPage.tsx
index 1c2778a08..37daf3c92 100644
--- a/apps/client/src/views/cuesheet/CuesheetPage.tsx
+++ b/apps/client/src/views/cuesheet/CuesheetPage.tsx
@@ -3,8 +3,8 @@ import { useDisclosure } from '@mantine/hooks';
import IconButton from '../../common/components/buttons/IconButton';
import NavigationMenu from '../../common/components/navigation-menu/NavigationMenu';
-import useViewEditor from '../../common/components/navigation-menu/useViewEditor';
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
+import { getIsViewLocked } from '../../externals';
import CuesheetOverview from '../../features/overview/CuesheetOverview';
import CuesheetEditModal from './cuesheet-edit-modal/CuesheetEditModal';
@@ -14,18 +14,19 @@ import CuesheetTableWrapper from './CuesheetTableWrapper';
import styles from './CuesheetPage.module.scss';
export default function CuesheetPage() {
- const { isViewLocked } = useViewEditor({ isLockable: true });
const [isMenuOpen, menuHandler] = useDisclosure();
useWindowTitle('Cuesheet');
+ const isLocked = getIsViewLocked();
+
return (
<>
- {!isViewLocked && (
+ {!isLocked && (
diff --git a/apps/client/src/views/cuesheet/CuesheetTableWrapper.tsx b/apps/client/src/views/cuesheet/CuesheetTableWrapper.tsx
index 797c834b4..8e16b2cfc 100644
--- a/apps/client/src/views/cuesheet/CuesheetTableWrapper.tsx
+++ b/apps/client/src/views/cuesheet/CuesheetTableWrapper.tsx
@@ -1,30 +1,66 @@
-import { memo, useMemo } from 'react';
+import { memo, use, useEffect, useMemo } from 'react';
import { useSessionStorage } from '@mantine/hooks';
import EmptyPage from '../../common/components/state/EmptyPage';
+import { PresetContext } from '../../common/context/PresetContext';
import useCustomFields from '../../common/hooks-query/useCustomFields';
import { useFlatRundown } from '../../common/hooks-query/useRundown';
+import { sessionScope } from '../../externals';
import { AppMode, sessionKeys } from '../../ontimeConfig';
import CuesheetDnd from './cuesheet-dnd/CuesheetDnd';
import { makeCuesheetColumns } from './cuesheet-table/cuesheet-table-elements/cuesheetColsFactory';
import CuesheetTable from './cuesheet-table/CuesheetTable';
+import { useCuesheetPermissions } from './useTablePermissions';
export default memo(CuesheetTableWrapper);
function CuesheetTableWrapper() {
const { data: flatRundown, status: rundownStatus } = useFlatRundown();
const { data: customFields, status: customFieldStatus } = useCustomFields();
+ const setPermissions = useCuesheetPermissions((state) => state.setPermissions);
+ const preset = use(PresetContext);
+
+ // set permissions based on preset
+ useEffect(() => {
+ if (preset) {
+ const fullWrite = preset.options?.write === 'full';
+ setPermissions({
+ canChangeMode: preset.options?.write !== '-',
+ canCreateEntries: fullWrite,
+ canEditEntries: fullWrite,
+ canFlag: fullWrite || Boolean(preset.options?.write.includes('flag')),
+ canShare: false, // TODO: should be sessionScope === 'rw' when we have granular scopes
+ });
+ } else {
+ setPermissions({
+ canChangeMode: true,
+ canCreateEntries: true,
+ canEditEntries: true,
+ canFlag: true,
+ canShare: sessionScope === 'rw',
+ });
+ }
+ }, [preset, setPermissions]);
const [cuesheetMode] = useSessionStorage({
- key: sessionKeys.cuesheetMode,
- defaultValue: AppMode.Edit,
+ key: preset ? `${preset.alias}${sessionKeys.cuesheetMode}` : sessionKeys.cuesheetMode,
+ defaultValue: preset ? AppMode.Run : AppMode.Edit,
});
- const columns = useMemo(() => makeCuesheetColumns(customFields, cuesheetMode), [customFields, cuesheetMode]);
+
+ const columns = useMemo(
+ () => makeCuesheetColumns(customFields, cuesheetMode, preset),
+ [customFields, cuesheetMode, preset],
+ );
+
const isLoading = !customFields || !flatRundown || rundownStatus === 'pending' || customFieldStatus === 'pending';
return (
- {isLoading ? : }
+ {isLoading ? (
+
+ ) : (
+
+ )}
);
}
diff --git a/apps/client/src/views/cuesheet/cuesheet-table/CuesheetTable.tsx b/apps/client/src/views/cuesheet/cuesheet-table/CuesheetTable.tsx
index d1ceea187..7237a4952 100644
--- a/apps/client/src/views/cuesheet/cuesheet-table/CuesheetTable.tsx
+++ b/apps/client/src/views/cuesheet/cuesheet-table/CuesheetTable.tsx
@@ -1,12 +1,11 @@
import { memo, useCallback, useMemo } from 'react';
-import { useSessionStorage } from '@mantine/hooks';
import { useTableNav } from '@table-nav/react';
import { ColumnDef, getCoreRowModel, useReactTable } from '@tanstack/react-table';
import { OntimeEntry, TimeField } from 'ontime-types';
import { useEntryActions } from '../../../common/hooks/useEntryAction';
import { useFollowSelected } from '../../../common/hooks/useFollowComponent';
-import { AppMode, sessionKeys } from '../../../ontimeConfig';
+import { AppMode } from '../../../ontimeConfig';
import { usePersistedCuesheetOptions } from '../cuesheet.options';
import CuesheetBody from './cuesheet-table-elements/CuesheetBody';
@@ -20,16 +19,14 @@ import style from './CuesheetTable.module.scss';
interface CuesheetTableProps {
data: OntimeEntry[];
columns: ColumnDef[];
+ cuesheetMode: AppMode;
}
-export default function CuesheetTable({ data, columns }: CuesheetTableProps) {
+export default function CuesheetTable({ data, columns, cuesheetMode }: CuesheetTableProps) {
const { updateEntry, updateTimer } = useEntryActions();
const showDelayedTimes = usePersistedCuesheetOptions((state) => state.showDelayedTimes);
const hideTableSeconds = usePersistedCuesheetOptions((state) => state.hideTableSeconds);
- const [cuesheetMode] = useSessionStorage({
- key: sessionKeys.cuesheetMode,
- defaultValue: AppMode.Edit,
- });
+ const hideIndexColumn = usePersistedCuesheetOptions((state) => state.hideIndexColumn);
const { selectedRef, scrollRef } = useFollowSelected(cuesheetMode === AppMode.Run);
@@ -66,9 +63,11 @@ export default function CuesheetTable({ data, columns }: CuesheetTableProps) {
options: {
showDelayedTimes,
hideTableSeconds,
+ cuesheetMode,
+ hideIndexColumn,
},
}),
- [data, hideTableSeconds, showDelayedTimes, updateEntry, updateTimer],
+ [cuesheetMode, data, hideIndexColumn, hideTableSeconds, showDelayedTimes, updateEntry, updateTimer],
);
const { columnVisibility, columnOrder, columnSizing, resetColumnOrder, setColumnVisibility, setColumnSizing } =
@@ -129,7 +128,7 @@ export default function CuesheetTable({ data, columns }: CuesheetTableProps) {
/>
-
+
{table.getState().columnSizingInfo.isResizingColumn ? (
) : (
diff --git a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/BlockRow.tsx b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/BlockRow.tsx
index 80febf474..3cf787199 100644
--- a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/BlockRow.tsx
+++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/BlockRow.tsx
@@ -1,12 +1,10 @@
import { IoEllipsisHorizontal } from 'react-icons/io5';
-import { useSessionStorage } from '@mantine/hooks';
import { flexRender, Table } from '@tanstack/react-table';
import { EntryId, OntimeEntry, SupportedEntry } from 'ontime-types';
import IconButton from '../../../../common/components/buttons/IconButton';
import { useCurrentBlockId } from '../../../../common/hooks/useSocket';
-import { AppMode, sessionKeys } from '../../../../ontimeConfig';
-import { usePersistedCuesheetOptions } from '../../cuesheet.options';
+import { AppMode } from '../../../../ontimeConfig';
import { useCuesheetTableMenu } from '../cuesheet-table-menu/useCuesheetTableMenu';
import style from './BlockRow.module.scss';
@@ -23,11 +21,11 @@ interface BlockRowProps {
export default function BlockRow({ blockId, colour, hidePast, rowId, rowIndex, table }: BlockRowProps) {
const { currentBlockId } = useCurrentBlockId();
- const hideIndexColumn = usePersistedCuesheetOptions((state) => state.hideIndexColumn);
- const [cuesheetMode] = useSessionStorage({
- key: sessionKeys.cuesheetMode,
- defaultValue: AppMode.Edit,
- });
+ const { cuesheetMode, hideIndexColumn } = table.options.meta?.options ?? {
+ cuesheetMode: AppMode.Edit,
+ hideIndexColumn: false,
+ };
+
const openMenu = useCuesheetTableMenu((store) => store.openMenu);
if (hidePast && !currentBlockId) {
diff --git a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/CuesheetBody.tsx b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/CuesheetBody.tsx
index 123bcf94f..3bf15a18a 100644
--- a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/CuesheetBody.tsx
+++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/CuesheetBody.tsx
@@ -113,7 +113,7 @@ export default function CuesheetBody({ rowModel, selectedRef, table }: CuesheetB
if (entry.parent) {
const rundown = queryClient.getQueryData(RUNDOWN);
const parentEntry = rundown?.entries[entry.parent];
- parentBgColour = (parentEntry as OntimeBlock).colour ?? null;
+ parentBgColour = (parentEntry as OntimeBlock | undefined)?.colour ?? null;
}
return (
diff --git a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/CuesheetHeader.tsx b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/CuesheetHeader.tsx
index 3765db5ed..1d2b7956e 100644
--- a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/CuesheetHeader.tsx
+++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/CuesheetHeader.tsx
@@ -1,11 +1,10 @@
import { CSSProperties } from 'react';
import { horizontalListSortingStrategy, SortableContext } from '@dnd-kit/sortable';
-import { useSessionStorage } from '@mantine/hooks';
import { flexRender, HeaderGroup } from '@tanstack/react-table';
import { OntimeEntry } from 'ontime-types';
import { getAccessibleColour } from '../../../../common/utils/styleUtils';
-import { AppMode, sessionKeys } from '../../../../ontimeConfig';
+import { AppMode } from '../../../../ontimeConfig';
import { usePersistedCuesheetOptions } from '../../cuesheet.options';
import { SortableCell } from './SortableCell';
@@ -14,14 +13,11 @@ import style from '../CuesheetTable.module.scss';
interface CuesheetHeaderProps {
headerGroups: HeaderGroup[];
+ cuesheetMode: AppMode;
}
-export default function CuesheetHeader({ headerGroups }: CuesheetHeaderProps) {
+export default function CuesheetHeader({ headerGroups, cuesheetMode }: CuesheetHeaderProps) {
const hideIndexColumn = usePersistedCuesheetOptions((state) => state.hideIndexColumn);
- const [cuesheetMode] = useSessionStorage({
- key: sessionKeys.cuesheetMode,
- defaultValue: AppMode.Edit,
- });
return (
diff --git a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/EventRow.tsx b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/EventRow.tsx
index 34465f632..f6b5661fd 100644
--- a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/EventRow.tsx
+++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/EventRow.tsx
@@ -1,14 +1,12 @@
import { RefObject, useEffect, useRef } from 'react';
import { IoEllipsisHorizontal } from 'react-icons/io5';
-import { useSessionStorage } from '@mantine/hooks';
import { flexRender, Table } from '@tanstack/react-table';
import { OntimeEntry, OntimeEvent, RGBColour, SupportedEntry } from 'ontime-types';
import { colourToHex, cssOrHexToColour } from 'ontime-utils';
import IconButton from '../../../../common/components/buttons/IconButton';
import { cx, getAccessibleColour } from '../../../../common/utils/styleUtils';
-import { AppMode, sessionKeys } from '../../../../ontimeConfig';
-import { usePersistedCuesheetOptions } from '../../cuesheet.options';
+import { AppMode } from '../../../../ontimeConfig';
import { useCuesheetTableMenu } from '../cuesheet-table-menu/useCuesheetTableMenu';
import { observeRow, unobserveRow } from './rowObserver';
@@ -43,15 +41,13 @@ export default function EventRow({
table,
firstAfterBlock,
}: EventRowProps) {
- const hideIndexColumn = usePersistedCuesheetOptions((state) => state.hideIndexColumn);
- const [cuesheetMode] = useSessionStorage({
- key: sessionKeys.cuesheetMode,
- defaultValue: AppMode.Edit,
- });
+ const { cuesheetMode, hideIndexColumn } = table.options.meta?.options ?? {
+ cuesheetMode: AppMode.Edit,
+ hideIndexColumn: false,
+ };
+
const ownRef = useRef(null);
-
const isVisible = useVisibleRowsStore((state) => state.visibleRows.has(rowId));
-
const openMenu = useCuesheetTableMenu((store) => store.openMenu);
// register this row with the intersection observer
diff --git a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/MilestoneRow.tsx b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/MilestoneRow.tsx
index a543d55b9..fb5e07768 100644
--- a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/MilestoneRow.tsx
+++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/MilestoneRow.tsx
@@ -1,12 +1,10 @@
import { IoEllipsisHorizontal } from 'react-icons/io5';
-import { useSessionStorage } from '@mantine/hooks';
import { flexRender, Table } from '@tanstack/react-table';
import { EntryId, OntimeEntry, SupportedEntry } from 'ontime-types';
import IconButton from '../../../../common/components/buttons/IconButton';
import { cx, enDash } from '../../../../common/utils/styleUtils';
-import { AppMode, sessionKeys } from '../../../../ontimeConfig';
-import { usePersistedCuesheetOptions } from '../../cuesheet.options';
+import { AppMode } from '../../../../ontimeConfig';
import { useCuesheetTableMenu } from '../cuesheet-table-menu/useCuesheetTableMenu';
import style from './MilestoneRow.module.scss';
@@ -32,11 +30,11 @@ export default function MilestoneRow({
rowIndex,
table,
}: MilestoneRowProps) {
- const hideIndexColumn = usePersistedCuesheetOptions((state) => state.hideIndexColumn);
- const [cuesheetMode] = useSessionStorage({
- key: sessionKeys.cuesheetMode,
- defaultValue: AppMode.Edit,
- });
+ const { cuesheetMode, hideIndexColumn } = table.options.meta?.options ?? {
+ cuesheetMode: AppMode.Edit,
+ hideIndexColumn: false,
+ };
+
const openMenu = useCuesheetTableMenu((store) => store.openMenu);
return (
diff --git a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/cuesheetColsFactory.tsx b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/cuesheetColsFactory.tsx
index 2651e421b..46f469b2f 100644
--- a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/cuesheetColsFactory.tsx
+++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/cuesheetColsFactory.tsx
@@ -1,6 +1,6 @@
import { useCallback } from 'react';
import { CellContext, ColumnDef } from '@tanstack/react-table';
-import { CustomFields, isOntimeDelay, isOntimeEvent, OntimeEntry, TimeStrategy } from 'ontime-types';
+import { CustomFields, isOntimeDelay, isOntimeEvent, OntimeEntry, TimeStrategy, URLPreset } from 'ontime-types';
import { millisToString } from 'ontime-utils';
import DelayIndicator from '../../../../common/components/delay-indicator/DelayIndicator';
@@ -225,85 +225,113 @@ function MakeCustomField({ row, column, table }: CellContext[] {
+export function makeCuesheetColumns(
+ customFields: CustomFields,
+ cuesheetMode: AppMode,
+ preset: URLPreset | undefined,
+): ColumnDef[] {
const columnsDef: ColumnDef[] = [];
const modeAllowsWrite = cuesheetMode === AppMode.Edit;
+ const fullRead = preset ? preset.options?.read === 'full' : true;
+ const fullWrite = preset ? preset.options?.write === 'full' : true;
+ const canWriteKeys = preset?.options?.write ? new Set(preset.options.write.split(',')) : new Set();
+ const canReadKeys = preset?.options?.read ? new Set(preset.options.read.split(',')) : new Set();
- columnsDef.push({
- accessorKey: 'flag',
- id: 'flag',
- header: 'Flag',
- cell: MakeFlagField,
- size: 45,
- minSize: 45,
- meta: { canWrite: modeAllowsWrite },
- });
+ // helpers to check read/write for a given key
+ const canRead = (key: string) => fullRead || canReadKeys.has(key);
+ const canWrite = (key: string) => modeAllowsWrite && (fullWrite || canWriteKeys.has(key));
- columnsDef.push({
- accessorKey: 'cue',
- id: 'cue',
- header: 'Cue',
- cell: MakeSingleLineField,
- size: 75,
- minSize: 40,
- meta: { canWrite: modeAllowsWrite },
- });
+ if (canRead('flag')) {
+ columnsDef.push({
+ accessorKey: 'flag',
+ id: 'flag',
+ header: 'Flag',
+ cell: MakeFlagField,
+ size: 45,
+ minSize: 45,
+ meta: { canWrite: canWrite('flag') },
+ });
+ }
- columnsDef.push({
- accessorKey: 'timeStart',
- id: 'timeStart',
- header: 'Start',
- cell: MakeStart,
- size: 75,
- minSize: 75,
- meta: { canWrite: modeAllowsWrite },
- });
+ if (canRead('cue')) {
+ columnsDef.push({
+ accessorKey: 'cue',
+ id: 'cue',
+ header: 'Cue',
+ cell: MakeSingleLineField,
+ size: 75,
+ minSize: 40,
+ meta: { canWrite: canWrite('cue') },
+ });
+ }
- columnsDef.push({
- accessorKey: 'timeEnd',
- id: 'timeEnd',
- header: 'End',
- cell: MakeEnd,
- size: 75,
- minSize: 75,
- meta: { canWrite: modeAllowsWrite },
- });
+ if (canRead('timeStart')) {
+ columnsDef.push({
+ accessorKey: 'timeStart',
+ id: 'timeStart',
+ header: 'Start',
+ cell: MakeStart,
+ size: 75,
+ minSize: 75,
+ meta: { canWrite: canWrite('timeStart') },
+ });
+ }
- columnsDef.push({
- accessorKey: 'duration',
- id: 'duration',
- header: 'Duration',
- cell: MakeDuration,
- size: 75,
- minSize: 75,
- meta: { canWrite: modeAllowsWrite },
- });
+ if (canRead('timeEnd')) {
+ columnsDef.push({
+ accessorKey: 'timeEnd',
+ id: 'timeEnd',
+ header: 'End',
+ cell: MakeEnd,
+ size: 75,
+ minSize: 75,
+ meta: { canWrite: canWrite('timeEnd') },
+ });
+ }
- columnsDef.push({
- accessorKey: 'title',
- id: 'title',
- header: 'Title',
- cell: MakeSingleLineField,
- size: 250,
- minSize: 75,
- meta: { canWrite: modeAllowsWrite },
- });
+ if (canRead('duration')) {
+ columnsDef.push({
+ accessorKey: 'duration',
+ id: 'duration',
+ header: 'Duration',
+ cell: MakeDuration,
+ size: 75,
+ minSize: 75,
+ meta: { canWrite: canWrite('duration') },
+ });
+ }
- columnsDef.push({
- accessorKey: 'note',
- id: 'note',
- header: 'Note',
- cell: MakeMultiLineField,
- size: 250,
- minSize: 75,
- meta: { canWrite: modeAllowsWrite },
- });
+ if (canRead('title')) {
+ columnsDef.push({
+ accessorKey: 'title',
+ id: 'title',
+ header: 'Title',
+ cell: MakeSingleLineField,
+ size: 250,
+ minSize: 75,
+ meta: { canWrite: canWrite('title') },
+ });
+ }
+
+ if (canRead('note')) {
+ columnsDef.push({
+ accessorKey: 'note',
+ id: 'note',
+ header: 'Note',
+ cell: MakeMultiLineField,
+ size: 250,
+ minSize: 75,
+ meta: { canWrite: canWrite('note') },
+ });
+ }
// custom fields at the end
const customFieldKeys = Object.keys(customFields);
for (let i = 0; i < customFieldKeys.length; i++) {
const key = customFieldKeys[i];
+ const permissionKey = `custom-${key}`;
+ if (!canRead(permissionKey)) continue;
columnsDef.push({
accessorKey: key,
id: key,
@@ -313,7 +341,7 @@ export function makeCuesheetColumns(customFields: CustomFields, cuesheetMode: Ap
minSize: 75,
meta: {
colour: customFields[key].colour,
- canWrite: true,
+ canWrite: canWrite(permissionKey),
},
});
}
diff --git a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-menu/CuesheetTableMenu.tsx b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-menu/CuesheetTableMenu.tsx
index 9be15e695..8745d9a5e 100644
--- a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-menu/CuesheetTableMenu.tsx
+++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-menu/CuesheetTableMenu.tsx
@@ -5,6 +5,7 @@ import { SupportedEntry } from 'ontime-types';
import { PositionedDropdownMenu } from '../../../../common/components/dropdown-menu/DropdownMenu';
import { useEntryActions } from '../../../../common/hooks/useEntryAction';
import { useCuesheetEditModal } from '../../cuesheet-edit-modal/useCuesheetEditModal';
+import { useCuesheetPermissions } from '../../useTablePermissions';
import { useCuesheetTableMenu } from './useCuesheetTableMenu';
@@ -14,6 +15,7 @@ function CuesheetTableMenu() {
const { isOpen, entryId, entryIndex, parentId, flag, position, closeMenu } = useCuesheetTableMenu();
const { addEntry, clone, deleteEntry, move, updateEntry } = useEntryActions();
const showModal = useCuesheetEditModal((state) => state.setEditableEntry);
+ const permissions = useCuesheetPermissions();
if (!isOpen) {
return null;
@@ -24,14 +26,20 @@ function CuesheetTableMenu() {
isOpen
onClose={closeMenu}
items={[
- { type: 'item', label: 'Edit...', onClick: () => showModal(entryId), icon: IoOptions },
+ {
+ type: 'item',
+ label: 'Edit...',
+ onClick: () => showModal(entryId),
+ icon: IoOptions,
+ disabled: !permissions.canEditEntries,
+ },
{ type: 'divider' },
{
type: 'item',
label: flag ? 'Remove flag' : 'Add flag',
onClick: () => updateEntry({ id: entryId, flag: !flag }),
icon: IoDuplicateOutline,
- disabled: flag === null,
+ disabled: flag === null || !permissions.canFlag,
},
{ type: 'divider' },
{
@@ -39,18 +47,21 @@ function CuesheetTableMenu() {
label: 'Add event above',
onClick: () => addEntry({ type: SupportedEntry.Event, parent: parentId }, { before: entryId }),
icon: IoAdd,
+ disabled: !permissions.canCreateEntries,
},
{
type: 'item',
label: 'Add event below',
onClick: () => addEntry({ type: SupportedEntry.Event, parent: parentId }, { after: entryId }),
icon: IoAdd,
+ disabled: !permissions.canCreateEntries,
},
{
type: 'item',
label: 'Clone event',
onClick: () => clone(entryId),
icon: IoDuplicateOutline,
+ disabled: !permissions.canCreateEntries,
},
{ type: 'divider' },
{
@@ -58,13 +69,14 @@ function CuesheetTableMenu() {
label: 'Move up',
onClick: () => move(entryId, 'up'),
icon: IoArrowUp,
- disabled: entryIndex < 1,
+ disabled: entryIndex < 1 || !permissions.canEditEntries,
},
{
type: 'item',
label: 'Move down',
onClick: () => move(entryId, 'down'),
icon: IoArrowDown,
+ disabled: !permissions.canEditEntries,
},
{ type: 'divider' },
{
@@ -72,6 +84,7 @@ function CuesheetTableMenu() {
label: 'Delete',
onClick: () => deleteEntry([entryId]),
icon: IoTrash,
+ disabled: !permissions.canEditEntries,
},
]}
position={position}
diff --git a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-settings/CuesheetShareModal.tsx b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-settings/CuesheetShareModal.tsx
index 2a19c74c3..7562367f1 100644
--- a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-settings/CuesheetShareModal.tsx
+++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-settings/CuesheetShareModal.tsx
@@ -1,5 +1,6 @@
import { Toolbar } from '@base-ui-components/react/toolbar';
import { useDisclosure } from '@mantine/hooks';
+import { OntimeView } from 'ontime-types';
import Button from '../../../../common/components/buttons/Button';
import RotatedLink from '../../../../common/components/icons/RotatedLink';
@@ -29,7 +30,9 @@ function CuesheetShareModal() {
showBackdrop
showCloseButton
bodyElements={
- showModalContent ? : null
+ showModalContent ? (
+
+ ) : null
}
/>
>
diff --git a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-settings/CuesheetTableSettings.tsx b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-settings/CuesheetTableSettings.tsx
index d33cc3a2a..f420fbccd 100644
--- a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-settings/CuesheetTableSettings.tsx
+++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-settings/CuesheetTableSettings.tsx
@@ -1,4 +1,4 @@
-import { ReactNode } from 'react';
+import { ReactNode, use } from 'react';
import { IoChevronDown, IoOptions, IoSettingsOutline } from 'react-icons/io5';
import { Popover } from '@base-ui-components/react/popover';
import { Toggle } from '@base-ui-components/react/toggle';
@@ -12,9 +12,11 @@ import Button from '../../../../common/components/buttons/Button';
import Checkbox from '../../../../common/components/checkbox/Checkbox';
import * as Editor from '../../../../common/components/editor-utils/EditorUtils';
import PopoverContents from '../../../../common/components/popover/Popover';
+import { PresetContext } from '../../../../common/context/PresetContext';
import { cx } from '../../../../common/utils/styleUtils';
import { AppMode, sessionKeys } from '../../../../ontimeConfig';
import { usePersistedCuesheetOptions } from '../../cuesheet.options';
+import { useCuesheetPermissions } from '../../useTablePermissions';
import CuesheetShareModal from './CuesheetShareModal';
@@ -33,9 +35,12 @@ export default function CuesheetTableSettings({
handleResetReordering,
handleClearToggles,
}: CuesheetTableSettingsProps) {
+ const canShare = useCuesheetPermissions((state) => state.canShare);
+ const preset = use(PresetContext);
+
const [cuesheetMode, setCuesheetMode] = useSessionStorage({
- key: sessionKeys.cuesheetMode,
- defaultValue: AppMode.Edit,
+ key: preset ? `${preset.alias}${sessionKeys.cuesheetMode}` : sessionKeys.cuesheetMode,
+ defaultValue: preset ? AppMode.Run : AppMode.Edit,
});
const toggleCuesheetMode = (mode: AppMode[]) => {
@@ -54,7 +59,6 @@ export default function CuesheetTableSettings({
handleResetReordering={handleResetReordering}
handleClearToggles={handleClearToggles}
/>
-
} value={AppMode.Run} className={style.radioButton}>
Run
@@ -64,8 +68,12 @@ export default function CuesheetTableSettings({
-
-
+ {canShare && (
+ <>
+
+
+ >
+ )}
);
}
diff --git a/apps/client/src/views/cuesheet/cuesheet.options.ts b/apps/client/src/views/cuesheet/cuesheet.options.ts
index feb76eec7..623b99e07 100644
--- a/apps/client/src/views/cuesheet/cuesheet.options.ts
+++ b/apps/client/src/views/cuesheet/cuesheet.options.ts
@@ -1,3 +1,4 @@
+import { CustomFields } from 'ontime-types';
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
@@ -40,3 +41,22 @@ export const usePersistedCuesheetOptions = create()(
},
),
);
+
+export const cuesheetDefaultColumns = [
+ { value: 'flag', label: 'Flag' },
+ { value: 'cue', label: 'Cue' },
+ { value: 'title', label: 'Title' },
+ { value: 'timeStart', label: 'Time start' },
+ { value: 'timeEnd', label: 'Time end' },
+ { value: 'duration', label: 'Duration' },
+ { value: 'note', label: 'Note' },
+];
+
+export function makeCuesheetCustomColumns(customFields: CustomFields) {
+ return Object.entries(customFields).map(([key, field]) => {
+ return {
+ value: `custom-${key}`,
+ label: field.label,
+ };
+ });
+}
diff --git a/apps/client/src/views/cuesheet/useTablePermissions.tsx b/apps/client/src/views/cuesheet/useTablePermissions.tsx
new file mode 100644
index 000000000..f4fa1b785
--- /dev/null
+++ b/apps/client/src/views/cuesheet/useTablePermissions.tsx
@@ -0,0 +1,27 @@
+import { create } from 'zustand';
+
+interface CuesheetPermissionsStore {
+ canChangeMode: boolean;
+ canCreateEntries: boolean;
+ canEditEntries: boolean;
+ canFlag: boolean;
+ canShare: boolean;
+ setPermissions: (permissions: Omit) => void;
+}
+
+export const useCuesheetPermissions = create((set) => ({
+ canChangeMode: false,
+ canCreateEntries: false,
+ canEditEntries: false,
+ canFlag: false,
+ canShare: false,
+ setPermissions(permissions) {
+ set({
+ canChangeMode: permissions.canChangeMode,
+ canFlag: permissions.canFlag,
+ canCreateEntries: permissions.canCreateEntries,
+ canEditEntries: permissions.canEditEntries,
+ canShare: permissions.canShare,
+ });
+ },
+}));
diff --git a/apps/client/src/views/studio/studio.options.ts b/apps/client/src/views/studio/studio.options.ts
index cdc95b968..89fe5074b 100644
--- a/apps/client/src/views/studio/studio.options.ts
+++ b/apps/client/src/views/studio/studio.options.ts
@@ -1,9 +1,10 @@
-import { useMemo } from 'react';
+import { use, useMemo } from 'react';
import { useSearchParams } from 'react-router';
import { getTimeOption } from '../../common/components/view-params-editor/common.options';
import { OptionTitle } from '../../common/components/view-params-editor/constants';
import { ViewOption } from '../../common/components/view-params-editor/viewParams.types';
+import { PresetContext } from '../../common/context/PresetContext';
import { isStringBoolean } from '../../features/viewers/common/viewUtils';
export const getStudioOptions = (timeFormat: string): ViewOption[] => [
@@ -31,10 +32,12 @@ type StudioOptions = {
* Utility extract the view options from URL Params
* the names and fallback are manually matched with timerOptions
*/
-function getOptionsFromParams(searchParams: URLSearchParams): StudioOptions {
- // we manually make an object that matches the key above
+function getOptionsFromParams(searchParams: URLSearchParams, defaultValues?: URLSearchParams): StudioOptions {
+ // Helper to get value from either source, prioritizing defaultValues
+ const getValue = (key: string) => defaultValues?.get(key) ?? searchParams.get(key);
+
return {
- hideCards: isStringBoolean(searchParams.get('hideCards')),
+ hideCards: isStringBoolean(getValue('hideCards')),
};
}
@@ -43,6 +46,12 @@ function getOptionsFromParams(searchParams: URLSearchParams): StudioOptions {
*/
export function useStudioOptions(): StudioOptions {
const [searchParams] = useSearchParams();
- const options = useMemo(() => getOptionsFromParams(searchParams), [searchParams]);
+ const maybePreset = use(PresetContext);
+
+ const options = useMemo(() => {
+ const defaultValues = maybePreset ? new URLSearchParams(maybePreset.search) : undefined;
+ return getOptionsFromParams(searchParams, defaultValues);
+ }, [maybePreset, searchParams]);
+
return options;
}
diff --git a/apps/client/src/views/timeline/timeline.options.ts b/apps/client/src/views/timeline/timeline.options.ts
index a67e99f84..b0ea293c2 100644
--- a/apps/client/src/views/timeline/timeline.options.ts
+++ b/apps/client/src/views/timeline/timeline.options.ts
@@ -1,9 +1,10 @@
-import { useMemo } from 'react';
+import { use, useMemo } from 'react';
import { useSearchParams } from 'react-router';
import { getTimeOption } from '../../common/components/view-params-editor/common.options';
import { OptionTitle } from '../../common/components/view-params-editor/constants';
import { ViewOption } from '../../common/components/view-params-editor/viewParams.types';
+import { PresetContext } from '../../common/context/PresetContext';
import { isStringBoolean } from '../../features/viewers/common/viewUtils';
export const getTimelineOptions = (timeFormat: string): ViewOption[] => {
@@ -41,11 +42,13 @@ type TimelineOptions = {
* Utility extract the view options from URL Params
* the names and fallback are manually matched with timerOptions
*/
-function getOptionsFromParams(searchParams: URLSearchParams): TimelineOptions {
- // we manually make an object that matches the key above
+function getOptionsFromParams(searchParams: URLSearchParams, defaultValues?: URLSearchParams): TimelineOptions {
+ // Helper to get value from either source, prioritizing defaultValues
+ const getValue = (key: string) => defaultValues?.get(key) ?? searchParams.get(key);
+
return {
- hidePast: isStringBoolean(searchParams.get('hidePast')),
- autosize: isStringBoolean(searchParams.get('autosize')),
+ hidePast: isStringBoolean(getValue('hidePast')),
+ autosize: isStringBoolean(getValue('autosize')),
};
}
@@ -54,6 +57,12 @@ function getOptionsFromParams(searchParams: URLSearchParams): TimelineOptions {
*/
export function useTimelineOptions(): TimelineOptions {
const [searchParams] = useSearchParams();
- const options = useMemo(() => getOptionsFromParams(searchParams), [searchParams]);
+ const maybePreset = use(PresetContext);
+
+ const options = useMemo(() => {
+ const defaultValues = maybePreset ? new URLSearchParams(maybePreset.search) : undefined;
+ return getOptionsFromParams(searchParams, defaultValues);
+ }, [maybePreset, searchParams]);
+
return options;
}
diff --git a/apps/client/src/views/timer/timer.options.ts b/apps/client/src/views/timer/timer.options.ts
index da406e9b7..4d88636b9 100644
--- a/apps/client/src/views/timer/timer.options.ts
+++ b/apps/client/src/views/timer/timer.options.ts
@@ -1,4 +1,4 @@
-import { useMemo } from 'react';
+import { use, useMemo } from 'react';
import { useSearchParams } from 'react-router';
import { CustomFields, OntimeEvent, TimerType } from 'ontime-types';
import { validateTimerType } from 'ontime-utils';
@@ -12,6 +12,7 @@ import {
import { OptionTitle } from '../../common/components/view-params-editor/constants';
import { ViewOption } from '../../common/components/view-params-editor/viewParams.types';
import { makeOptionsFromCustomFields } from '../../common/components/view-params-editor/viewParams.utils';
+import { PresetContext } from '../../common/context/PresetContext';
import { isStringBoolean, makeColourString } from '../../features/viewers/common/viewUtils';
// manually match the properties of TimerType excluding the None
@@ -197,31 +198,35 @@ type TimerOptions = {
* Utility extract the view options from URL Params
* the names and fallbacks are manually matched with timerOptions
*/
-function getOptionsFromParams(searchParams: URLSearchParams): TimerOptions {
- const timerType = validateTimerType(searchParams.get('timerType'), TimerType.None);
- // we manually make an object that matches the key above
- return {
- hideClock: isStringBoolean(searchParams.get('hideClock')),
- hideCards: isStringBoolean(searchParams.get('hideCards')),
- hideProgress: isStringBoolean(searchParams.get('hideProgress')),
- hideMessage: isStringBoolean(searchParams.get('hideMessage')),
- hideSecondary: isStringBoolean(searchParams.get('hideSecondary')),
- hideLogo: isStringBoolean(searchParams.get('hideLogo')),
- hideTimerSeconds: isStringBoolean(searchParams.get('hideTimerSeconds')),
- removeLeadingZeros: !isStringBoolean(searchParams.get('showLeadingZeros')),
+function getOptionsFromParams(searchParams: URLSearchParams, defaultValues?: URLSearchParams): TimerOptions {
+ // Helper to get value from either source, prioritizing defaultValues
+ const getValue = (key: string) => defaultValues?.get(key) ?? searchParams.get(key);
- mainSource: searchParams.get('main') as keyof OntimeEvent | null,
- secondarySource: searchParams.get('secondary-src') as keyof OntimeEvent | null,
+ // Get timerType from either source
+ const timerType = validateTimerType(getValue('timerType'), TimerType.None);
+
+ return {
+ hideClock: isStringBoolean(getValue('hideClock')),
+ hideCards: isStringBoolean(getValue('hideCards')),
+ hideProgress: isStringBoolean(getValue('hideProgress')),
+ hideMessage: isStringBoolean(getValue('hideMessage')),
+ hideSecondary: isStringBoolean(getValue('hideSecondary')),
+ hideLogo: isStringBoolean(getValue('hideLogo')),
+ hideTimerSeconds: isStringBoolean(getValue('hideTimerSeconds')),
+ removeLeadingZeros: !isStringBoolean(getValue('showLeadingZeros')),
+
+ mainSource: getValue('main') as keyof OntimeEvent | null,
+ secondarySource: getValue('secondary-src') as keyof OntimeEvent | null,
// none doesnt make sense as a configuration of the view
timerType: timerType === TimerType.None ? undefined : timerType,
- freezeOvertime: isStringBoolean(searchParams.get('freezeOvertime')),
- freezeMessage: searchParams.get('freezeMessage') ?? '',
- hidePhase: isStringBoolean(searchParams.get('hidePhase')),
+ freezeOvertime: isStringBoolean(getValue('freezeOvertime')),
+ freezeMessage: getValue('freezeMessage') ?? '',
+ hidePhase: isStringBoolean(getValue('hidePhase')),
- font: searchParams.get('font') ?? undefined,
- keyColour: makeColourString(searchParams.get('keyColour')),
- textColour: makeColourString(searchParams.get('textColour')),
+ font: getValue('font') ?? undefined,
+ keyColour: makeColourString(getValue('keyColour')),
+ textColour: makeColourString(getValue('textColour')),
};
}
@@ -230,6 +235,12 @@ function getOptionsFromParams(searchParams: URLSearchParams): TimerOptions {
*/
export function useTimerOptions(): TimerOptions {
const [searchParams] = useSearchParams();
- const options = useMemo(() => getOptionsFromParams(searchParams), [searchParams]);
+ const maybePreset = use(PresetContext);
+
+ const options = useMemo(() => {
+ const defaultValues = maybePreset ? new URLSearchParams(maybePreset.search) : undefined;
+ return getOptionsFromParams(searchParams, defaultValues);
+ }, [maybePreset, searchParams]);
+
return options;
}
diff --git a/apps/server/src/api-data/db/migration/db.migration.v3.ts b/apps/server/src/api-data/db/migration/db.migration.v3.ts
index 3058698d5..74cf8651b 100644
--- a/apps/server/src/api-data/db/migration/db.migration.v3.ts
+++ b/apps/server/src/api-data/db/migration/db.migration.v3.ts
@@ -104,13 +104,14 @@ type old_URLPreset = {
/**
* migrates a url presets from v3 to v4
* - pathAndParams split into a target and search
+ *
*/
export function migrateURLPresets(jsonData: object): URLPreset[] | undefined {
if (is.objectWithKeys(jsonData, ['urlPresets']) && is.array(jsonData.urlPresets)) {
const oldURLPresets = structuredClone(jsonData.urlPresets) as old_URLPreset;
const newURLPreset: URLPreset[] = oldURLPresets.map(({ enabled, alias, pathAndParams }) => {
const [target, search] = pathAndParams.split('?');
- return { enabled, alias, target, search };
+ return { enabled, alias, target, search, options: {} } as URLPreset;
});
return newURLPreset;
}
diff --git a/apps/server/src/api-data/db/migration/migration.test.ts b/apps/server/src/api-data/db/migration/migration.test.ts
index 908c68c6c..8e757cde4 100644
--- a/apps/server/src/api-data/db/migration/migration.test.ts
+++ b/apps/server/src/api-data/db/migration/migration.test.ts
@@ -2,6 +2,7 @@ import {
AutomationSettings,
CustomFields,
EndAction,
+ OntimeView,
ProjectData,
Rundown,
Settings,
@@ -202,16 +203,18 @@ describe('v3 to v4', () => {
{
enabled: true,
alias: 'clock',
- target: 'timer',
+ target: OntimeView.Timer,
search:
'showLeadingZeros=true&timerType=clock&hideClock=true&hideCards=true&hideProgress=true&hideMessage=true&hideSecondary=true&hideLogo=true',
+ options: {},
},
{
enabled: true,
alias: 'minimal',
- target: 'timer',
+ target: OntimeView.Timer,
search:
'showLeadingZeros=true&hideClock=true&hideCards=true&hideProgress=true&hideMessage=true&hideSecondary=true&hideLogo=true',
+ options: {},
},
];
const newUrlPreset = v3.migrateURLPresets(oldDb);
@@ -249,10 +252,10 @@ describe('v3 to v4', () => {
colour: '#E80000',
},
};
- const { customFields, translationTable } = v3.migrateCustomFields(oldDb)!;
- expect(customFields).toEqual(expectCustomFields);
-
- expect(translationTable).toEqual(
+ const parsedData = v3.migrateCustomFields(oldDb);
+ expect(parsedData).not.toBeUndefined();
+ expect(parsedData?.customFields).toEqual(expectCustomFields);
+ expect(parsedData?.translationTable).toEqual(
new Map([
['song', 'Song_and_Dance'],
['artist', 'Artist_and_Host'],
diff --git a/apps/server/src/api-data/session/__tests__/session.service.test.ts b/apps/server/src/api-data/session/__tests__/session.service.test.ts
index 82b11e41f..4a925df3e 100644
--- a/apps/server/src/api-data/session/__tests__/session.service.test.ts
+++ b/apps/server/src/api-data/session/__tests__/session.service.test.ts
@@ -1,50 +1,150 @@
-import { generateAuthenticatedUrl } from '../session.service.js';
+import { generateShareUrl } from '../session.service.js';
describe('generateAuthenticatedUrl()', () => {
describe('for local IP addresses', () => {
it('generates a link without locking or authentication', () => {
- const localhostNotLocked = generateAuthenticatedUrl('http://localhost:3000', 'timer', false, false);
+ const localhostNotLocked = generateShareUrl('http://localhost:3000', 'timer', {
+ lockConfig: false,
+ lockNav: false,
+ authenticate: false,
+ });
expect(localhostNotLocked.toString()).toBe('http://localhost:3000/timer');
});
- it('generates a link with IP locking enabled', () => {
- const ipLocked = generateAuthenticatedUrl('http://192.168.10.173:4001', 'timer', true, false);
- expect(ipLocked.toString()).toBe('http://192.168.10.173:4001/timer?locked=true');
+ it('generates a link with navigation locking enabled', () => {
+ const ipLocked = generateShareUrl('http://192.168.10.173:4001', 'timer', {
+ lockConfig: false,
+ lockNav: true,
+ authenticate: false,
+ });
+ expect(ipLocked.toString()).toBe('http://192.168.10.173:4001/timer?n=1');
});
- it('generates a link with authentication token and IP locking', () => {
- const withAuth = generateAuthenticatedUrl('http://192.168.10.173:4001', 'timer', true, true, undefined, '1234');
- expect(withAuth.toString()).toBe('http://192.168.10.173:4001/timer?token=1234&locked=true');
+ it('generates a link with authentication token and navigation locking', () => {
+ const withAuth = generateShareUrl('http://192.168.10.173:4001', 'timer', {
+ lockConfig: false,
+ lockNav: true,
+ authenticate: true,
+ hash: '1234',
+ });
+ expect(withAuth.toString()).toBe('http://192.168.10.173:4001/timer?token=1234&n=1');
+ });
+
+ it('generates a link to an unlocked preset', () => {
+ const withAuth = generateShareUrl('http://192.168.10.173:4001', 'timer', {
+ lockConfig: false,
+ lockNav: false,
+ authenticate: false,
+ preset: 'minimal',
+ });
+ expect(withAuth.toString()).toBe('http://192.168.10.173:4001/minimal');
+ });
+
+ it('generates a link to an unlocked preset without navigation', () => {
+ const withAuth = generateShareUrl('http://192.168.10.173:4001', 'timer', {
+ lockConfig: false,
+ lockNav: true,
+ authenticate: false,
+ preset: 'minimal',
+ });
+ expect(withAuth.toString()).toBe('http://192.168.10.173:4001/minimal?n=1');
+ });
+
+ it('generates a link to a locked preset', () => {
+ const withAuth = generateShareUrl('http://192.168.10.173:4001', 'timer', {
+ lockConfig: true,
+ lockNav: false,
+ authenticate: false,
+ preset: 'minimal',
+ });
+ expect(withAuth.toString()).toBe('http://192.168.10.173:4001/preset/minimal');
+ });
+
+ it('generates a link to a locked preset', () => {
+ const withAuth = generateShareUrl('http://192.168.10.173:4001', 'cuesheet', {
+ lockConfig: false,
+ lockNav: false,
+ authenticate: false,
+ preset: 'some-cuesheet-preset',
+ });
+ expect(withAuth.toString()).toBe('http://192.168.10.173:4001/preset/some-cuesheet-preset');
});
});
describe('for ontime-cloud URLs', () => {
it('generates a link without locking or authentication', () => {
- const cloudNotLocked = generateAuthenticatedUrl(
- 'https://cloud.getontime.no/userhash',
- 'timer',
- false,
- false,
- 'prefix',
- );
+ const cloudNotLocked = generateShareUrl('https://cloud.getontime.no/userhash', 'timer', {
+ lockConfig: false,
+ lockNav: false,
+ authenticate: false,
+ prefix: 'prefix',
+ });
expect(cloudNotLocked.toString()).toBe('https://cloud.getontime.no/prefix/timer');
});
- it('generates a link with IP locking enabled', () => {
- const ipLocked = generateAuthenticatedUrl('https://cloud.getontime.no/prefix', 'timer', true, false, 'prefix');
- expect(ipLocked.toString()).toBe('https://cloud.getontime.no/prefix/timer?locked=true');
+ it('generates a link with navigation locking enabled', () => {
+ const ipLocked = generateShareUrl('https://cloud.getontime.no/prefix', 'timer', {
+ lockConfig: false,
+ lockNav: true,
+ authenticate: false,
+ prefix: 'prefix',
+ });
+ expect(ipLocked.toString()).toBe('https://cloud.getontime.no/prefix/timer?n=1');
});
- it('generates a link with authentication token and IP locking', () => {
- const withAuth = generateAuthenticatedUrl(
- 'https://cloud.getontime.no/prefix',
- 'timer',
- true,
- true,
- 'prefix',
- '1234',
- );
- expect(withAuth.toString()).toBe('https://cloud.getontime.no/prefix/timer?token=1234&locked=true');
+ it('generates a link with authentication token and navigation locking', () => {
+ const withAuth = generateShareUrl('https://cloud.getontime.no/prefix', 'timer', {
+ lockConfig: false,
+ lockNav: true,
+ authenticate: true,
+ prefix: 'prefix',
+ hash: '1234',
+ });
+ expect(withAuth.toString()).toBe('https://cloud.getontime.no/prefix/timer?token=1234&n=1');
+ });
+
+ it('generates a link to an unlocked preset', () => {
+ const withAuth = generateShareUrl('https://cloud.getontime.no/prefix', 'timer', {
+ lockConfig: false,
+ lockNav: false,
+ authenticate: false,
+ preset: 'minimal',
+ prefix: 'prefix',
+ });
+ expect(withAuth.toString()).toBe('https://cloud.getontime.no/prefix/minimal');
+ });
+
+ it('generates a link to an unlocked preset without navigation', () => {
+ const withAuth = generateShareUrl('https://cloud.getontime.no/prefix', 'timer', {
+ lockConfig: false,
+ lockNav: true,
+ authenticate: false,
+ preset: 'minimal',
+ prefix: 'prefix',
+ });
+ expect(withAuth.toString()).toBe('https://cloud.getontime.no/prefix/minimal?n=1');
+ });
+
+ it('generates a link to a locked preset', () => {
+ const withAuth = generateShareUrl('https://cloud.getontime.no/prefix', 'timer', {
+ lockConfig: true,
+ lockNav: false,
+ authenticate: false,
+ prefix: 'prefix',
+ preset: 'minimal',
+ });
+ expect(withAuth.toString()).toBe('https://cloud.getontime.no/prefix/preset/minimal');
+ });
+
+ it('generates a link to a locked preset', () => {
+ const withAuth = generateShareUrl('https://cloud.getontime.no/prefix', 'cuesheet', {
+ lockConfig: false,
+ lockNav: false,
+ authenticate: false,
+ prefix: 'prefix',
+ preset: 'some-cuesheet-preset',
+ });
+ expect(withAuth.toString()).toBe('https://cloud.getontime.no/prefix/preset/some-cuesheet-preset');
});
});
});
diff --git a/apps/server/src/api-data/session/session.router.ts b/apps/server/src/api-data/session/session.router.ts
index 61de685ee..743b2859f 100644
--- a/apps/server/src/api-data/session/session.router.ts
+++ b/apps/server/src/api-data/session/session.router.ts
@@ -29,12 +29,12 @@ router.get('/info', async (_req: Request, res: Response
router.post('/url', validateGenerateUrl, (req: Request, res: Response) => {
try {
- const url = sessionService.generateAuthenticatedUrl(
- req.body.baseUrl,
- req.body.path,
- req.body.lock,
- req.body.authenticate,
- );
+ const url = sessionService.generateShareUrl(req.body.baseUrl, req.body.path, {
+ authenticate: req.body.authenticate,
+ lockConfig: req.body.lockConfig,
+ lockNav: req.body.lockNav,
+ preset: req.body.preset,
+ });
res.status(200).send({ url: url.toString() });
} catch (error) {
const message = getErrorMessage(error);
diff --git a/apps/server/src/api-data/session/session.service.ts b/apps/server/src/api-data/session/session.service.ts
index 31b110d66..62d56e9e9 100644
--- a/apps/server/src/api-data/session/session.service.ts
+++ b/apps/server/src/api-data/session/session.service.ts
@@ -1,4 +1,4 @@
-import { GetInfo, SessionStats } from 'ontime-types';
+import { GetInfo, LinkOptions, OntimeView, SessionStats } from 'ontime-types';
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
import { publicDir } from '../../setup/index.js';
@@ -57,22 +57,25 @@ export const hashedPassword = hasPassword ? hashPassword(password as string) : u
/**
* Generates a pre-authenticated URL by injecting a token in the URL params
*/
-export function generateAuthenticatedUrl(
+export function generateShareUrl(
baseUrl: string,
- path: string,
- lock: boolean,
- authenticate: boolean,
- prefix = routerPrefix,
- hash = hashedPassword,
+ canonicalPath: string,
+ { authenticate, lockConfig, lockNav, preset, prefix = routerPrefix, hash = hashedPassword }: LinkOptions,
): URL {
const url = new URL(baseUrl);
- url.pathname = prefix ? `${prefix}/${path}` : path;
+
+ // if the config is locked and we are in a preset, we hide the canonical path
+ const shouldMaskPath = Boolean(preset) && (canonicalPath === OntimeView.Cuesheet || lockConfig);
+ const maybePresetPath = shouldMaskPath ? `preset/${preset}` : preset || canonicalPath;
+ url.pathname = prefix ? `${prefix}/${maybePresetPath}` : maybePresetPath;
if (authenticate && hash) {
url.searchParams.append('token', hash);
}
- if (lock) {
- url.searchParams.append('locked', 'true');
+
+ if (lockNav) {
+ url.searchParams.append('n', '1');
}
+
return url;
}
diff --git a/apps/server/src/api-data/session/session.validation.ts b/apps/server/src/api-data/session/session.validation.ts
index 0872588c4..3903703b4 100644
--- a/apps/server/src/api-data/session/session.validation.ts
+++ b/apps/server/src/api-data/session/session.validation.ts
@@ -3,9 +3,14 @@ import { requestValidationFunction } from '../validation-utils/validationFunctio
export const validateGenerateUrl = [
body('baseUrl').isString().trim().notEmpty(),
- body('path').isString().trim(),
- body('lock').isBoolean(),
+ body('path').isString().trim().notEmpty(),
+
body('authenticate').isBoolean(),
+ body('lockConfig').isBoolean(),
+ body('lockNav').isBoolean(),
+ body('preset').optional().isString().trim().notEmpty(),
+ body('prefix').optional().isString().trim().notEmpty(),
+ body('hash').optional().isString().trim().notEmpty(),
requestValidationFunction,
];
diff --git a/apps/server/src/api-data/url-presets/urlPresets.parser.ts b/apps/server/src/api-data/url-presets/urlPresets.parser.ts
index 781381ffc..3e2c1fd57 100644
--- a/apps/server/src/api-data/url-presets/urlPresets.parser.ts
+++ b/apps/server/src/api-data/url-presets/urlPresets.parser.ts
@@ -16,16 +16,17 @@ export function parseUrlPresets(data: Partial, emitError?: ErrorE
const newPresets: URLPreset[] = [];
for (const preset of data.urlPresets) {
- if (!preset.alias || !preset.search || !preset.target) {
+ if (!preset.alias || !preset.target) {
emitError?.(`Invalid URL preset: ${JSON.stringify(preset)}`);
continue;
}
- const newPreset = {
+ const newPreset: URLPreset = {
enabled: preset.enabled ?? false,
alias: preset.alias,
target: preset.target,
- search: preset.search,
+ search: preset.search ?? '',
+ options: preset?.options,
};
newPresets.push(newPreset);
}
diff --git a/apps/server/src/api-data/url-presets/urlPresets.router.ts b/apps/server/src/api-data/url-presets/urlPresets.router.ts
index 650ba57d8..f9518bee5 100644
--- a/apps/server/src/api-data/url-presets/urlPresets.router.ts
+++ b/apps/server/src/api-data/url-presets/urlPresets.router.ts
@@ -20,6 +20,7 @@ router.post('/', validateNewPreset, async (req: Request, res: Response {
page.locator('data-testid=timer-view');
await expect(page).toHaveURL('http://localhost:4001/timer');
});
+
+ test('not-found', async ({ page }) => {
+ await page.goto('http://localhost:4001/not-found');
+
+ await expect(page).toHaveTitle(/ontime/);
+ await expect(page.getByRole('heading', { name: 'Not found' })).toBeVisible();
+
+ await page.goto('http://localhost:4001/preset/not-found');
+
+ await expect(page).toHaveTitle(/ontime/);
+ await expect(page.getByRole('heading', { name: 'Not found' })).toBeVisible();
+ });
});
async function openNavigationMenu(page: Page) {
diff --git a/e2e/tests/features/201-message-control.spec.ts b/e2e/tests/features/201-message-control.spec.ts
index 98145bd87..90358fef2 100644
--- a/e2e/tests/features/201-message-control.spec.ts
+++ b/e2e/tests/features/201-message-control.spec.ts
@@ -14,4 +14,8 @@ test('message control sends messages to screens', async ({ context }) => {
await featurePage.goto('http://localhost:4001/timer');
await featurePage.waitForLoadState('load', { timeout: 5000 });
await expect(featurePage.getByText('testing stage')).toBeVisible();
+
+ await editorPage.getByRole('button', { name: /toggle timer message/i }).click({ timeout: 5000 });
+
+ await expect(featurePage.getByText('TIME NOW')).toBeVisible();
});
diff --git a/e2e/tests/features/206-url-preset.spec.ts b/e2e/tests/features/206-url-preset.spec.ts
index 2214915e3..df2c17e2b 100644
--- a/e2e/tests/features/206-url-preset.spec.ts
+++ b/e2e/tests/features/206-url-preset.spec.ts
@@ -1,28 +1,224 @@
import { expect, test } from '@playwright/test';
-test('URL preset feature, it should redirect to given URL', async ({ page }) => {
- await page.goto('http://localhost:4001/editor');
+const aliasName = 'testing';
+const aliasUrl =
+ 'www.getontime.no/team/timer/?hideTimerSeconds=true&showLeadingZeros=true&freezeOvertime=true&hidePhase=true&hideClock=true&hideCards=true&hideProgress=true&hideMessage=true&hideSecondary=true&hideLogo=true';
- // open settings
- await page.getByRole('button', { name: 'Toggle settings' }).click();
- await page.getByRole('button', { name: 'URL Presets' }).click();
+test.describe('URL Preset', () => {
+ test.beforeAll(async ({ browser }) => {
+ const context = await browser.newContext();
+ const page = await context.newPage();
+ await page.goto('http://localhost:4001/editor');
- // create preset
- await page.getByRole('heading', { name: 'URL presets New' }).getByRole('button').scrollIntoViewIfNeeded();
- await page.getByRole('heading', { name: 'URL presets New' }).getByRole('button').click();
+ // Create the preset that will be used by other tests
+ await page.getByRole('button', { name: 'Toggle settings' }).click();
+ await page.getByRole('button', { name: 'URL Presets' }).click();
- await page.locator('input[name="alias"]').click();
- await page.locator('input[name="alias"]').fill('testing');
+ await page.getByRole('heading', { name: 'URL presets New' }).getByRole('button').scrollIntoViewIfNeeded();
+ await page.getByRole('heading', { name: 'URL presets New' }).getByRole('button').click();
- await page.getByRole('textbox', { name: 'Paste URL' }).click();
- await page.getByRole('textbox', { name: 'Paste URL' }).fill('www.getontime.no/team/countdown');
- await page.getByRole('button', { name: 'Generate' }).click();
+ await page.locator('input[name="alias"]').click();
+ await page.locator('input[name="alias"]').fill(aliasName);
- await page.getByRole('combobox').filter({ hasText: 'Countdown' });
+ await page.getByRole('textbox', { name: 'Paste URL' }).click();
+ await page.getByRole('textbox', { name: 'Paste URL' }).fill(aliasUrl);
+ await page.getByRole('button', { name: 'Generate' }).click();
- await page.getByRole('button', { name: 'Save' }).click();
+ await page.getByRole('combobox').filter({ hasText: 'Timer' });
+ await page.getByRole('button', { name: 'Save' }).click();
- // make sure preset works
- await page.goto('http://localhost:4001/testing');
- await expect(page.getByTestId('countdown-view')).toBeVisible();
+ await page.close();
+ });
+
+ test('Unwrapping a preset from a view', async ({ page }) => {
+ await page.goto('http://localhost:4001/timer');
+
+ // 1. the URL points to the view
+ expect(page.url().includes('hideTimerSeconds=true')).not.toBeTruthy();
+ expect(page.url().includes('alias=testing')).not.toBeTruthy();
+
+ // open settings
+ await page.getByRole('button', { name: 'Toggle settings' }).click();
+ await page
+ .locator('div')
+ .filter({ hasText: /^testingApply$/ })
+ .getByRole('button')
+ .click();
+ await expect(page.getByRole('button', { name: 'Applied' })).toBeVisible();
+
+ // 2. the URL contains the preset
+ expect(page.url().includes('hideTimerSeconds=true')).toBeTruthy();
+ expect(page.url().includes('alias=testing')).toBeTruthy();
+ });
+
+ test('Sharing a link to an unwrapped preset', async ({ page }) => {
+ await page.goto('http://localhost:4001/editor');
+
+ // open settings
+ await page.getByRole('button', { name: 'Toggle settings' }).click();
+ await page.getByRole('button', { name: 'Share link' }).click();
+
+ // select options
+ await page.getByRole('combobox').filter({ hasText: 'Timer' }).click();
+ await page.getByText('URL Preset: testing').click();
+
+ // create and verify link
+ await page.getByRole('button', { name: 'Create share link' }).click();
+ await expect(page.getByTestId('copy-link')).toContainText('testing');
+ await expect(page.getByTestId('copy-link')).not.toContainText('n=1');
+
+ // verify the preset
+ const generatedUrl = await page.getByTestId('copy-link').textContent();
+ await page.goto(generatedUrl);
+
+ // make sure preset works in mask mode
+ await expect(page.getByTestId('timer-view')).toBeVisible();
+ // the url unwraps the preset
+ expect(page.url().includes('hideTimerSeconds=true')).toBeTruthy();
+ expect(page.url().includes('alias=testing')).toBeTruthy();
+ expect(page.url().includes('/preset/testing')).not.toBeTruthy();
+
+ // the menus work
+ await expect(page.getByTestId('navigation__toggle-settings')).toBeVisible();
+ });
+
+ test('Sharing a link to a masked preset', async ({ page }) => {
+ await page.goto('http://localhost:4001/editor');
+
+ // open settings
+ await page.getByRole('button', { name: 'Toggle settings' }).click();
+ await page.getByRole('button', { name: 'Share link' }).click();
+
+ // select options
+ await page.getByRole('combobox').filter({ hasText: 'Timer' }).click();
+ await page.getByText('URL Preset: testing').click();
+ await page.locator('button[name="lockNav"]').click();
+ await page.locator('button[name="lockConfig"]').click();
+
+ // create and verify link
+ await page.getByRole('button', { name: 'Create share link' }).click();
+ await expect(page.getByTestId('copy-link')).toContainText('/preset/testing');
+ await expect(page.getByTestId('copy-link')).toContainText('n=1');
+
+ // verify the preset
+ const generatedUrl = await page.getByTestId('copy-link').textContent();
+ await page.goto(generatedUrl);
+
+ // make sure preset works in mask mode
+ await expect(page.getByTestId('timer-view')).toBeVisible();
+ // the url masks the preset
+ expect(page.url().includes('hideTimerSeconds=true')).not.toBeTruthy();
+ expect(page.url().includes('alias=testing')).not.toBeTruthy();
+ expect(page.url().includes('/preset/testing')).toBeTruthy();
+ });
+});
+
+test.describe('Sharing from cuesheet', () => {
+ test.beforeAll(async ({ browser }) => {
+ const context = await browser.newContext();
+ const page = await context.newPage();
+ await page.goto('http://localhost:4001/editor');
+
+ // we create some elements to test with
+ await page.getByRole('button', { name: 'Clear all' }).click();
+ await page.getByRole('button', { name: 'Delete all' }).click();
+ await page.getByRole('button', { name: 'Create Event' }).click();
+ await page.getByTestId('entry-1').getByTestId('block__title').click();
+ await page.getByTestId('entry-1').getByTestId('block__title').fill('title 1');
+ await page.getByTestId('entry-1').getByTestId('block__title').press('Enter');
+
+ await page.close();
+ });
+
+ test('Sharing a link with readonly permissions', async ({ page }) => {
+ await page.goto('http://localhost:4001/cuesheet');
+ await expect(page.getByTestId('cuesheet')).toBeVisible();
+
+ await page.getByRole('button', { name: 'Share...' }).click();
+
+ // configure share for readonly
+ await page.getByRole('textbox').fill('cuesheet-read-test');
+ await page.getByText('Custom write').click();
+ await page.getByText('Custom read').click();
+ await page.locator('button[name="lockNav"]').click();
+ await page.getByTestId('write-flag').click();
+ await page.getByTestId('write-cue').click();
+ await page.getByTestId('write-title').click();
+ await page.getByTestId('write-timeStart').click();
+ await page.getByTestId('write-timeEnd').click();
+ await page.getByTestId('write-duration').click();
+ await page.getByTestId('write-note').click();
+
+ // create and verify link
+ await page.getByRole('button', { name: 'Create share link' }).click();
+ await expect(page.getByTestId('copy-link')).toContainText('preset/cuesheet-read-test');
+ await expect(page.getByTestId('copy-link')).toContainText('n=1');
+
+ // verify the preset
+ const generatedUrl = await page.getByTestId('copy-link').textContent();
+ await page.goto(generatedUrl);
+
+ // the menu is locked and we cant make shares
+ await expect(page.getByTestId('cuesheet')).toBeVisible();
+ await expect(page.getByTestId('navigation__toggle-settings')).toBeHidden();
+ await expect(page.getByRole('button', { name: 'Share...' })).toBeHidden();
+
+ // check that we are locked and cannot edit
+ await page.getByRole('button', { name: 'Edit' }).click();
+
+ // Verify that the title is visible but not editable
+ await expect(page.getByTestId('cuesheet-event').getByText('title 1')).toBeVisible();
+ await expect(page.getByTestId('cuesheet-event').locator('input')).toBeHidden();
+
+ // other elements are still there
+ await expect(page.getByRole('cell', { name: 'Duration' })).toBeVisible();
+ });
+
+ test('Sharing a link with scoped read-write permissions', async ({ page }) => {
+ await page.goto('http://localhost:4001/cuesheet');
+ await expect(page.getByTestId('cuesheet')).toBeVisible();
+
+ await page.getByRole('button', { name: 'Share...' }).click();
+
+ // configure share for readonly
+ await page.getByRole('textbox').fill('cuesheet-scope-test');
+ await page.getByText('Custom write').click();
+ await page.getByText('Custom read').click();
+ await page.locator('button[name="lockNav"]').click();
+ await page.getByTestId('write-flag').click();
+ await page.getByTestId('write-cue').click();
+ await page.getByTestId('write-timeStart').click();
+ await page.getByTestId('write-timeEnd').click();
+ await page.getByTestId('write-duration').click();
+ await page.getByTestId('write-note').click();
+ await page.getByTestId('read-flag').click();
+ await page.getByTestId('read-cue').click();
+ await page.getByTestId('read-timeStart').click();
+ await page.getByTestId('read-timeEnd').click();
+ await page.getByTestId('read-duration').click();
+ await page.getByTestId('read-note').click();
+
+ // create and verify link
+ await page.getByRole('button', { name: 'Create share link' }).click();
+ await expect(page.getByTestId('copy-link')).toContainText('preset/cuesheet-scope-test');
+ await expect(page.getByTestId('copy-link')).toContainText('n=1');
+
+ // verify the preset
+ const generatedUrl = await page.getByTestId('copy-link').textContent();
+ await page.goto(generatedUrl);
+
+ // the menu is locked and we cant make shares
+ await expect(page.getByTestId('cuesheet')).toBeVisible();
+ await expect(page.getByTestId('navigation__toggle-settings')).toBeHidden();
+ await expect(page.getByRole('button', { name: 'Share...' })).toBeHidden();
+
+ // check that we are locked and cannot edit
+ await page.getByRole('button', { name: 'Edit' }).click();
+
+ // Verify that the title is visible and editable
+ await expect(page.getByTestId('cuesheet-event').getByRole('cell', { name: 'title' })).toBeVisible();
+
+ // other elements are not there
+ await expect(page.getByRole('cell', { name: 'Duration' })).toBeHidden();
+ });
});
diff --git a/e2e/tests/features/207-view-params.spec.ts b/e2e/tests/features/207-view-params.spec.ts
index 3f2a54570..f738e90a5 100644
--- a/e2e/tests/features/207-view-params.spec.ts
+++ b/e2e/tests/features/207-view-params.spec.ts
@@ -8,7 +8,8 @@ test('View params configures timer view', async ({ page }) => {
await page.mouse.move(Math.random() * 100, Math.random() * 100);
await page.getByTestId('navigation__toggle-settings').click();
await page.locator('label').filter({ hasText: 'Hide Time NowHides the Time' }).locator('span').nth(2).click();
- await page.getByRole('button', { name: 'Apply' }).click();
+ await page.getByTestId('apply-view-params').click();
+ await page.getByTestId('close-view-params').click();
await expect(page.getByText('TIME NOW', { exact: true })).not.toBeInViewport();
await expect(page).toHaveURL(/.*hideClock=true/);
diff --git a/packages/types/src/api/session-controller/BackendResponse.type.ts b/packages/types/src/api/session-controller/BackendResponse.type.ts
new file mode 100644
index 000000000..5d370c55f
--- /dev/null
+++ b/packages/types/src/api/session-controller/BackendResponse.type.ts
@@ -0,0 +1,8 @@
+export type LinkOptions = {
+ authenticate: boolean;
+ lockConfig: boolean;
+ lockNav: boolean;
+ preset?: string;
+ prefix?: string;
+ hash?: string;
+};
diff --git a/packages/types/src/definitions/core/UrlPreset.type.ts b/packages/types/src/definitions/core/UrlPreset.type.ts
index 474bdeb29..326e9f82b 100644
--- a/packages/types/src/definitions/core/UrlPreset.type.ts
+++ b/packages/types/src/definitions/core/UrlPreset.type.ts
@@ -13,10 +13,25 @@ export enum OntimeView {
ProjectInfo = 'info',
}
-export type URLPreset = {
- // presets cannot target the editor view
- target: Omit;
+export type OntimeViewPresettable = Exclude;
+
+type BaseURLPreset = {
+ target: OntimeViewPresettable;
enabled: boolean;
alias: string;
search: string;
+ options?: Record;
};
+
+type CuesheetUrlPreset = {
+ target: OntimeView.Cuesheet;
+ enabled: boolean;
+ alias: string;
+ search: string;
+ options: {
+ read: string;
+ write: string;
+ };
+};
+
+export type URLPreset = BaseURLPreset | CuesheetUrlPreset;
diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts
index 82ad05399..83a45038c 100644
--- a/packages/types/src/index.ts
+++ b/packages/types/src/index.ts
@@ -52,7 +52,7 @@ export type { ViewSettings } from './definitions/core/Views.type.js';
export type { TimeFormat } from './definitions/core/TimeFormat.type.js';
// ---> URL Presets
-export { OntimeView, type URLPreset } from './definitions/core/UrlPreset.type.js';
+export { OntimeView, type URLPreset, type OntimeViewPresettable } from './definitions/core/UrlPreset.type.js';
// ---> Custom Fields
export type {
@@ -63,6 +63,7 @@ export type {
} from './definitions/core/CustomFields.type.js';
// SERVER RESPONSES
+export type { QuickStartData } from './api/db/db.type.js';
export type {
AuthenticationStatus,
NetworkInterface,
@@ -76,13 +77,13 @@ export type {
SessionStats,
ProjectLogoResponse,
} from './api/ontime-controller/BackendResponse.type.js';
-export type { QuickStartData } from './api/db/db.type.js';
export type {
EventPostPayload,
PatchWithId,
ProjectRundownsList,
TransientEventPayload,
} from './api/rundown-controller/BackendResponse.type.js';
+export type { LinkOptions } from './api/session-controller/BackendResponse.type.js';
// web socket
export { MessageTag } from './api/websocket/data.type.js';