mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-14 20:03:52 +00:00
feat: cuesheet sharing
feat: locked presets refactor: simplify locked param refactor: create share links
This commit is contained in:
committed by
Carlos Valente
parent
1695b4dc68
commit
6c6f5c2c0f
+117
-11
@@ -1,14 +1,17 @@
|
||||
import { ComponentType, lazy, Suspense, useMemo } from 'react';
|
||||
import { Navigate, Route, useLocation } from 'react-router';
|
||||
import { OntimeView, OntimeViewPresettable } from 'ontime-types';
|
||||
import { ComponentType, lazy, Suspense, useEffect, useMemo } from 'react';
|
||||
import { Navigate, Route, useLocation, useNavigate, useParams } from 'react-router';
|
||||
import { OntimeView, OntimeViewPresettable, URLPreset } from 'ontime-types';
|
||||
|
||||
import ViewNavigationMenu from './common/components/navigation-menu/ViewNavigationMenu';
|
||||
import { PresetContext } from './common/context/PresetContext';
|
||||
import { useClientPath } from './common/hooks/useClientPath';
|
||||
import useUrlPresets from './common/hooks-query/useUrlPresets';
|
||||
import { getRouteFromPreset } from './common/utils/urlPresets';
|
||||
import Log from './features/log/Log';
|
||||
import Loader from './views/common/loader/Loader';
|
||||
import NotFound from './views/common/not-found/NotFound';
|
||||
import ViewLoader from './views/ViewLoader';
|
||||
import { getIsViewLocked, sessionScope } from './externals';
|
||||
import { initializeSentry } from './sentry.config';
|
||||
|
||||
const Timer = lazy(() => import('./views/timer/Timer'));
|
||||
@@ -42,7 +45,7 @@ export default function AppRouter() {
|
||||
path='timer'
|
||||
element={
|
||||
<ViewLoader>
|
||||
<ViewNavigationMenu isLockable />
|
||||
<ViewNavigationMenu isViewLocked={getIsViewLocked()} />
|
||||
<Timer />
|
||||
</ViewLoader>
|
||||
}
|
||||
@@ -51,7 +54,7 @@ export default function AppRouter() {
|
||||
path='countdown'
|
||||
element={
|
||||
<ViewLoader>
|
||||
<ViewNavigationMenu isLockable />
|
||||
<ViewNavigationMenu isViewLocked={getIsViewLocked()} />
|
||||
<Countdown />
|
||||
</ViewLoader>
|
||||
}
|
||||
@@ -60,7 +63,7 @@ export default function AppRouter() {
|
||||
path='backstage'
|
||||
element={
|
||||
<ViewLoader>
|
||||
<ViewNavigationMenu isLockable />
|
||||
<ViewNavigationMenu isViewLocked={getIsViewLocked()} />
|
||||
<Backstage />
|
||||
</ViewLoader>
|
||||
}
|
||||
@@ -69,7 +72,7 @@ export default function AppRouter() {
|
||||
path='studio'
|
||||
element={
|
||||
<ViewLoader>
|
||||
<ViewNavigationMenu isLockable />
|
||||
<ViewNavigationMenu isViewLocked={getIsViewLocked()} />
|
||||
<StudioClock />
|
||||
</ViewLoader>
|
||||
}
|
||||
@@ -78,7 +81,7 @@ export default function AppRouter() {
|
||||
path='timeline'
|
||||
element={
|
||||
<ViewLoader>
|
||||
<ViewNavigationMenu isLockable />
|
||||
<ViewNavigationMenu isViewLocked={getIsViewLocked()} />
|
||||
<Timeline />
|
||||
</ViewLoader>
|
||||
}
|
||||
@@ -87,12 +90,11 @@ export default function AppRouter() {
|
||||
path='info'
|
||||
element={
|
||||
<ViewLoader>
|
||||
<ViewNavigationMenu isLockable suppressSettings />
|
||||
<ViewNavigationMenu suppressSettings isViewLocked={getIsViewLocked()} />
|
||||
<ProjectInfo />
|
||||
</ViewLoader>
|
||||
}
|
||||
/>
|
||||
|
||||
{/*/!* Protected Routes *!/*/}
|
||||
<Route path='editor' element={<Editor />} />
|
||||
<Route path='cuesheet' element={<Cuesheet />} />
|
||||
@@ -104,7 +106,6 @@ export default function AppRouter() {
|
||||
</ViewLoader>
|
||||
}
|
||||
/>
|
||||
|
||||
{/*/!* Protected Routes - Elements *!/*/}
|
||||
<Route
|
||||
path='rundown'
|
||||
@@ -138,7 +139,112 @@ export default function AppRouter() {
|
||||
</EditorFeatureWrapper>
|
||||
}
|
||||
/>
|
||||
{/**
|
||||
* If the views are prefixed with the "preset" path, we are in a locked preset
|
||||
* Locked presets do not expose their parameters
|
||||
*/}
|
||||
<Route path='preset/:alias' element={<PresetView />} />
|
||||
|
||||
{/**
|
||||
* If we havent matched any views or presets, we may be in an unlocked preset
|
||||
* Unlocked presets are unwrapped to expose their target and parameters
|
||||
*/}
|
||||
<Route path='*' element={<RedirectPreset />} />
|
||||
</SentryRouter>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
const PresetViewMap: Record<OntimeViewPresettable, ComponentType> = {
|
||||
[OntimeView.Cuesheet]: Cuesheet,
|
||||
[OntimeView.Operator]: Operator,
|
||||
[OntimeView.Timer]: Timer,
|
||||
[OntimeView.Backstage]: Backstage,
|
||||
[OntimeView.Timeline]: Timeline,
|
||||
[OntimeView.StudioClock]: StudioClock,
|
||||
[OntimeView.Countdown]: Countdown,
|
||||
[OntimeView.ProjectInfo]: ProjectInfo,
|
||||
};
|
||||
|
||||
/**
|
||||
* This view will mask a configured canonical route
|
||||
* and inject the preset search parameters to context
|
||||
* User are not able to configure the parameters locked presets
|
||||
*/
|
||||
function PresetView() {
|
||||
const { data, status } = useUrlPresets();
|
||||
const { alias } = useParams();
|
||||
|
||||
const preset: URLPreset | undefined = useMemo(() => {
|
||||
if (status === 'pending' || !alias) return;
|
||||
return data.find((p) => p.alias === alias && p.enabled);
|
||||
}, [data, status, alias]);
|
||||
|
||||
if (status === 'pending') {
|
||||
return <Loader />;
|
||||
}
|
||||
|
||||
/**
|
||||
* We need to check the session scope to determine if the user can navigate
|
||||
* If the user has a global scope, they can navigate freely
|
||||
* Otherwise, they are locked to the preset view
|
||||
*/
|
||||
const showNav = sessionScope === 'rw';
|
||||
|
||||
/**
|
||||
* If we are in a preset path but cannot find a preset, we will need to show a not found page
|
||||
* This can happen if the preset was deleted or disabled
|
||||
*/
|
||||
if (!preset) {
|
||||
return (
|
||||
<>
|
||||
<ViewNavigationMenu isViewLocked={!showNav} suppressSettings />
|
||||
<NotFound />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Locked presets do not allow configuration changes
|
||||
* Whether the user can navigate is determined by the locked param
|
||||
*/
|
||||
const Component = PresetViewMap[preset.target as OntimeViewPresettable];
|
||||
return (
|
||||
<PresetContext value={preset}>
|
||||
{preset.target !== OntimeView.Cuesheet && (
|
||||
<ViewNavigationMenu isViewLocked={getIsViewLocked()} suppressSettings />
|
||||
)}
|
||||
{Component ? <Component /> : <NotFound />}
|
||||
</PresetContext>
|
||||
);
|
||||
}
|
||||
|
||||
function RedirectPreset() {
|
||||
const { data, status } = useUrlPresets();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
// checks if we are in a preset path and resolves a destination URL
|
||||
const destination = useMemo(() => {
|
||||
if (status === 'pending') return null;
|
||||
return getRouteFromPreset(location, data);
|
||||
}, [data, location, status]);
|
||||
|
||||
// if we have a destination, we will navigate to it
|
||||
useEffect(() => {
|
||||
if (destination) {
|
||||
navigate(`/${destination}`, { replace: true });
|
||||
}
|
||||
}, [destination, navigate]);
|
||||
|
||||
if (status === 'pending') {
|
||||
return <Loader />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ViewNavigationMenu isViewLocked={getIsViewLocked()} suppressSettings />
|
||||
<NotFound />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import axios from 'axios';
|
||||
import { GetInfo } from 'ontime-types';
|
||||
import { GetInfo, LinkOptions } from 'ontime-types';
|
||||
|
||||
import { apiEntryUrl } from './constants';
|
||||
|
||||
@@ -16,12 +16,7 @@ export async function getInfo(): Promise<GetInfo> {
|
||||
/**
|
||||
* HTTP request to get a pre-authenticated URL
|
||||
*/
|
||||
export async function generateUrl(
|
||||
baseUrl: string,
|
||||
path: string,
|
||||
lock: boolean,
|
||||
authenticate: boolean,
|
||||
): Promise<string> {
|
||||
const res = await axios.post(`${sessionPath}/url`, { baseUrl, path, lock, authenticate });
|
||||
export async function generateUrl(options: LinkOptions & { baseUrl: string; path: string }): Promise<string> {
|
||||
const res = await axios.post(`${sessionPath}/url`, options);
|
||||
return res.data.url;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
padding-inline: 1rem;
|
||||
min-width: min(680px, 90vw);
|
||||
min-height: min(200px, 10vh);
|
||||
max-width: min(800px, 90vw);
|
||||
max-width: min(900px, 90vw);
|
||||
|
||||
background-color: $gray-1250;
|
||||
color: $ui-white;
|
||||
|
||||
@@ -1,20 +1,23 @@
|
||||
import { memo } from 'react';
|
||||
import { useDisclosure, useHotkeys } from '@mantine/hooks';
|
||||
|
||||
import { useViewParamsEditorStore } from '../view-params-editor/viewParamsEditor.store';
|
||||
|
||||
import FloatingNavigation from './floating-navigation/FloatingNavigation';
|
||||
import ViewLockedIcon from './view-locked-icon/ViewLockedIcon';
|
||||
import NavigationMenu from './NavigationMenu';
|
||||
import useViewEditor from './useViewEditor';
|
||||
|
||||
interface ViewNavigationMenuProps {
|
||||
isLockable?: boolean;
|
||||
/** prevent navigation and settings*/
|
||||
isViewLocked?: boolean;
|
||||
/** prevent showing settings */
|
||||
suppressSettings?: boolean;
|
||||
}
|
||||
|
||||
export default memo(ViewNavigationMenu);
|
||||
function ViewNavigationMenu({ isLockable, suppressSettings }: ViewNavigationMenuProps) {
|
||||
function ViewNavigationMenu({ isViewLocked, suppressSettings }: ViewNavigationMenuProps) {
|
||||
const [isMenuOpen, menuHandler] = useDisclosure();
|
||||
const { showEditFormDrawer, isViewLocked } = useViewEditor({ isLockable });
|
||||
const { open: showEditFormDrawer } = useViewParamsEditorStore();
|
||||
|
||||
useHotkeys([
|
||||
[
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useSearchParams } from 'react-router';
|
||||
|
||||
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
|
||||
import { useViewParamsEditorStore } from '../view-params-editor/viewParamsEditor.store';
|
||||
|
||||
interface EditorVisibilityOptions {
|
||||
isLockable?: boolean;
|
||||
}
|
||||
|
||||
export default function useViewEditor({ isLockable }: EditorVisibilityOptions) {
|
||||
const [searchParams] = useSearchParams();
|
||||
const { open: showEditFormDrawer } = useViewParamsEditorStore();
|
||||
|
||||
const isViewLocked = useMemo(() => {
|
||||
if (!isLockable) {
|
||||
return false;
|
||||
}
|
||||
return isStringBoolean(searchParams.get('locked'));
|
||||
}, [isLockable, searchParams]);
|
||||
|
||||
return { showEditFormDrawer, isViewLocked };
|
||||
}
|
||||
@@ -3,6 +3,13 @@
|
||||
color: $gray-900;
|
||||
font-size: calc(1rem - 2px);
|
||||
color: $ui-white;
|
||||
|
||||
&[data-disabled] {
|
||||
.item {
|
||||
opacity: $opacity-disabled;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.horizontal {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
.emptyCell {
|
||||
margin-inline: auto;
|
||||
text-align: center;
|
||||
padding-top: 10vh;
|
||||
}
|
||||
|
||||
.empty {
|
||||
|
||||
@@ -24,6 +24,7 @@ interface EditFormDrawerProps {
|
||||
|
||||
export default memo(ViewParamsEditor);
|
||||
function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
|
||||
// TODO: can we ensure that the options update when the user loads an alias?
|
||||
const [_, setSearchParams] = useSearchParams();
|
||||
const { data: viewSettings } = useViewSettings();
|
||||
const { isOpen, close } = useViewParamsEditorStore();
|
||||
@@ -62,7 +63,7 @@ function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
|
||||
<Dialog.Popup className={style.drawer}>
|
||||
<div className={style.header}>
|
||||
<Dialog.Title>Customise</Dialog.Title>
|
||||
<IconButton variant='subtle-white' size='large' onClick={handleClose}>
|
||||
<IconButton variant='subtle-white' size='large' data-testid='close-view-params' onClick={handleClose}>
|
||||
<IoClose />
|
||||
</IconButton>
|
||||
</div>
|
||||
@@ -86,7 +87,13 @@ function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
|
||||
<Button variant='subtle' size='large' onClick={resetParams} type='reset'>
|
||||
Reset to default
|
||||
</Button>
|
||||
<Button variant='primary' size='large' form='edit-params-form' type='submit'>
|
||||
<Button
|
||||
variant='primary'
|
||||
size='large'
|
||||
form='edit-params-form'
|
||||
type='submit'
|
||||
data-testid='apply-view-params'
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createContext } from 'react';
|
||||
import { URLPreset } from 'ontime-types';
|
||||
|
||||
export const PresetContext = createContext<URLPreset | undefined>(undefined);
|
||||
@@ -0,0 +1,89 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
type Target = 'cuesheet' | 'timer' | 'clock' | 'countdown' | 'backstage' | 'studio';
|
||||
|
||||
interface SelectionState {
|
||||
[key: string]: boolean;
|
||||
}
|
||||
|
||||
interface ColumnPermissions {
|
||||
read: string[];
|
||||
write: string[];
|
||||
}
|
||||
|
||||
interface CuesheetLinksState {
|
||||
target: Target | null;
|
||||
readSelected: SelectionState;
|
||||
writeSelected: SelectionState;
|
||||
setTarget: (target: Target | null) => void;
|
||||
setField: (field: 'read' | 'write', key: string, value: boolean) => void;
|
||||
toggleField: (field: 'read' | 'write', key: string) => void;
|
||||
selectAll: (field: 'read' | 'write', keys: string[]) => void;
|
||||
clearAll: (field: 'read' | 'write', keys: string[]) => void;
|
||||
// Returns arrays of column keys that have read/write permissions if target is 'cuesheet'
|
||||
getSelections: () => ColumnPermissions | null;
|
||||
}
|
||||
|
||||
export const useCuesheetLinksStore = create<CuesheetLinksState>((set, get) => ({
|
||||
target: null,
|
||||
readSelected: {},
|
||||
writeSelected: {},
|
||||
setTarget: (target) => set({ target }),
|
||||
setField: (field, key, value) =>
|
||||
set((state) => ({
|
||||
...(field === 'read'
|
||||
? { readSelected: { ...state.readSelected, [key]: value } }
|
||||
: { writeSelected: { ...state.writeSelected, [key]: value } }),
|
||||
})),
|
||||
toggleField: (field, key) =>
|
||||
set((state) => ({
|
||||
...(field === 'read'
|
||||
? { readSelected: { ...state.readSelected, [key]: !state.readSelected[key] } }
|
||||
: { writeSelected: { ...state.writeSelected, [key]: !state.writeSelected[key] } }),
|
||||
})),
|
||||
selectAll: (field, keys) =>
|
||||
set((_state) => ({
|
||||
...(field === 'read'
|
||||
? {
|
||||
readSelected: keys.reduce((acc, key) => {
|
||||
acc[key] = true;
|
||||
return acc;
|
||||
}, {} as SelectionState),
|
||||
}
|
||||
: {
|
||||
writeSelected: keys.reduce((acc, key) => {
|
||||
acc[key] = true;
|
||||
return acc;
|
||||
}, {} as SelectionState),
|
||||
}),
|
||||
})),
|
||||
clearAll: (field, keys) =>
|
||||
set((_state) => ({
|
||||
...(field === 'read'
|
||||
? {
|
||||
readSelected: keys.reduce((acc, key) => {
|
||||
acc[key] = false;
|
||||
return acc;
|
||||
}, {} as SelectionState),
|
||||
}
|
||||
: {
|
||||
writeSelected: keys.reduce((acc, key) => {
|
||||
acc[key] = false;
|
||||
return acc;
|
||||
}, {} as SelectionState),
|
||||
}),
|
||||
})),
|
||||
getSelections: () => {
|
||||
const state = get();
|
||||
if (state.target !== 'cuesheet') return null;
|
||||
|
||||
return {
|
||||
read: Object.entries(state.readSelected)
|
||||
.filter(([_, selected]) => selected)
|
||||
.map(([key]) => key),
|
||||
write: Object.entries(state.writeSelected)
|
||||
.filter(([_, selected]) => selected)
|
||||
.map(([key]) => key),
|
||||
};
|
||||
},
|
||||
}));
|
||||
@@ -1,9 +1,11 @@
|
||||
import { resolvePath } from 'react-router';
|
||||
import { Path, resolvePath } from 'react-router';
|
||||
import { OntimeView, URLPreset } from 'ontime-types';
|
||||
|
||||
import {
|
||||
arePathsEquivalent,
|
||||
generatePathFromPreset,
|
||||
generateUrlPresetOptions,
|
||||
getCurrentPath,
|
||||
getRouteFromPreset,
|
||||
validateUrlPresetPath,
|
||||
} from '../urlPresets';
|
||||
@@ -29,12 +31,13 @@ describe('validateUrlPresetPaths()', () => {
|
||||
});
|
||||
|
||||
describe('getRouteFromPreset()', () => {
|
||||
const presets = [
|
||||
const presets: URLPreset[] = [
|
||||
{
|
||||
enabled: true,
|
||||
alias: 'demopage',
|
||||
target: 'timer',
|
||||
target: OntimeView.Timer,
|
||||
search: 'user=guest',
|
||||
options: {},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -70,28 +73,28 @@ describe('getRouteFromPreset()', () => {
|
||||
|
||||
describe('handle url sharing edge cases', () => {
|
||||
it('finds the correct preset when the url contains extra arguments', () => {
|
||||
const location = resolvePath('/demopage?locked=true&token=123');
|
||||
const location = resolvePath('/demopage?n=1&token=123');
|
||||
expect(getRouteFromPreset(location, presets)?.startsWith('timer?user=guest&alias=demopage')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('appends the feature params to the alias', () => {
|
||||
const location = resolvePath('/demopage?locked=true&token=123');
|
||||
expect(getRouteFromPreset(location, presets)).toBe('timer?user=guest&alias=demopage&locked=true&token=123');
|
||||
const location = resolvePath('/demopage?n=1&token=123');
|
||||
expect(getRouteFromPreset(location, presets)).toBe('timer?user=guest&alias=demopage&n=1&token=123');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('generatePathFromPreset()', () => {
|
||||
test.each([
|
||||
['timer', 'user=guest', 'demopage', 'timer?user=guest&alias=demopage'],
|
||||
['timer', 'user=admin', 'demopage', 'timer?user=admin&alias=demopage'],
|
||||
])('generates a path from a preset: %s', (target, path, alias, expected) => {
|
||||
expect(generatePathFromPreset(target, path, alias, null, null)).toEqual(expected);
|
||||
['timer', 'user=guest', 'demopage', false, 'timer?user=guest&alias=demopage'],
|
||||
['timer', 'user=admin', 'demopage', false, 'timer?user=admin&alias=demopage'],
|
||||
])('generates a path from a preset: %s', (target, search, alias, locked, expected) => {
|
||||
expect(generatePathFromPreset(target, search, alias, locked, null)).toEqual(expected);
|
||||
});
|
||||
|
||||
test('appends the feature params to the alias', () => {
|
||||
expect(generatePathFromPreset('timer', 'user=guest', 'demopage', 'true', '123')).toBe(
|
||||
'timer?user=guest&alias=demopage&locked=true&token=123',
|
||||
expect(generatePathFromPreset('timer', 'user=guest', 'demopage', true, '123')).toBe(
|
||||
'timer?user=guest&alias=demopage&n=1&token=123',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -108,9 +111,13 @@ describe('arePathsEquivalent()', () => {
|
||||
expect(arePathsEquivalent('timer?test=a', 'timer?test=a')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('checks whether we are in a locked preset', () => {
|
||||
expect(arePathsEquivalent('preset/minimal', 'preset/minimal?test=b')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('considers edge cases for the url sharing feature', () => {
|
||||
expect(arePathsEquivalent('timer?test=a&locked=true=token=123', 'timer?test=b')).toBeFalsy();
|
||||
expect(arePathsEquivalent('timer?test=a&locked=true=token=123', 'timer?test=a')).toBeTruthy();
|
||||
expect(arePathsEquivalent('timer?test=a&n=1=token=123', 'timer?test=b')).toBeFalsy();
|
||||
expect(arePathsEquivalent('timer?test=a&n=1=token=123', 'timer?test=a')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -172,3 +179,16 @@ describe('generateUrlPresetOptions', () => {
|
||||
expect(() => generateUrlPresetOptions('test', 'www.getontime.no/somethingelse/')).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCurrentPath()', () => {
|
||||
test.each([
|
||||
[resolvePath('http://localhost:4001/timer'), 'timer'],
|
||||
[resolvePath('http://192.168.0.1:654321/minimal'), 'minimal'],
|
||||
[resolvePath('https://user-hosted.io/cuesheet'), 'cuesheet'],
|
||||
[resolvePath('https://cloud.getontime.no/team-hash/op'), 'op'],
|
||||
[resolvePath('https://cloud.getontime.no/team-hash/backstage/?params-with-slash=true'), 'backstage'],
|
||||
[resolvePath('https://cloud.getontime.no/team-hash/timeline?params-are-ignored=true'), 'timeline'],
|
||||
])('resolves the current: %s', (location, expected) => {
|
||||
expect(getCurrentPath(location as Path)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,3 +11,4 @@ export const isAlphanumeric = /^[a-z0-9]+$/i;
|
||||
export const isASCII = /^[ -~]+$/; //https://catonmat.net/my-favorite-regex
|
||||
export const isASCIIorEmpty = /^$|^[ -~]+$/; //https://catonmat.net/my-favorite-regex
|
||||
export const isNotEmpty = /\S/;
|
||||
export const isUrlSafe = /^[a-zA-Z0-9_-]*$/; // https://stackoverflow.com/questions/24419067/validate-a-string-to-be-url-safe-using-regex
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Path, resolvePath } from 'react-router';
|
||||
import { OntimeView, URLPreset } from 'ontime-types';
|
||||
import { OntimeView, OntimeViewPresettable, URLPreset } from 'ontime-types';
|
||||
import { checkRegex } from 'ontime-utils';
|
||||
|
||||
/**
|
||||
@@ -27,62 +27,72 @@ export function validateUrlPresetPath(preset: string): { message: string; isVali
|
||||
return { isValid: true, message: 'ok' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility removes trailing slash from a string
|
||||
*/
|
||||
function removeTrailingSlash(text: string): string {
|
||||
return text.replace(/\/$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether the current location corresponds to a preset and returns the new path if necessary
|
||||
*/
|
||||
export function getRouteFromPreset(location: Path, urlPresets: URLPreset[]): string | null {
|
||||
// current url is the pathname without the leading slash
|
||||
const currentURL = location.pathname.substring(1);
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
|
||||
// check if we have token or locked in the search params
|
||||
const locked = searchParams.get('locked');
|
||||
const token = searchParams.get('token');
|
||||
|
||||
// we need to check if the whole url is an alias
|
||||
const foundPreset = urlPresets.find((preset) => preset.alias === removeTrailingSlash(currentURL) && preset.enabled);
|
||||
if (foundPreset) {
|
||||
// if so, we can redirect to the preset path
|
||||
return generatePathFromPreset(foundPreset.target, foundPreset.search, foundPreset.alias, locked, token);
|
||||
}
|
||||
|
||||
// if the current url is not an alias, we check if the alias is in the search parameters
|
||||
const presetOnPage = searchParams.get('alias');
|
||||
if (!presetOnPage) {
|
||||
// if we're already on a preset path, no need to redirect
|
||||
if (isPresetPath(location)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// NOTE: verify that this resolves correctly in cloud
|
||||
const currentPath = `${location.pathname}${location.search}`.substring(1);
|
||||
const currentURL = getCurrentPath(location);
|
||||
const token = new URLSearchParams(location.search).get('token');
|
||||
const isLocked = location.search.includes('n=1');
|
||||
|
||||
for (const preset of urlPresets) {
|
||||
// if the page has a known enabled alias, we check if we need to redirect
|
||||
if (preset.alias === presetOnPage && preset.enabled) {
|
||||
const newPath = generatePathFromPreset(preset.target, preset.search, preset.alias, locked, token);
|
||||
if (!arePathsEquivalent(currentPath, newPath)) {
|
||||
// if current path is out of date
|
||||
// return new path so we can redirect
|
||||
return newPath;
|
||||
}
|
||||
if (!preset.enabled) continue;
|
||||
/**
|
||||
* If the page is a known alias it would be like
|
||||
* /preset/{alias} <- locked to a preset
|
||||
* or
|
||||
* /{target}?alias={alias} <- unwrapped preset options
|
||||
*
|
||||
* we need to compare the saved preset to the current path to see if we need to redirect
|
||||
*/
|
||||
if (preset.alias === currentURL || preset.target === currentURL) {
|
||||
const newPath = generatePathFromPreset(preset.target, preset.search, preset.alias, isLocked, token);
|
||||
/**
|
||||
* if the current path is equivalent to the new path, we return null
|
||||
* this means we will not redirect
|
||||
*/
|
||||
return arePathsEquivalent(currentPath, newPath) ? null : newPath;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the current path accounting for the base URI
|
||||
* Returns the alias if it's a preset path, or the last segment otherwise
|
||||
*/
|
||||
export function getCurrentPath(location: Path): string {
|
||||
// 1. get path without query parameters
|
||||
const pathWithoutQuery = location.pathname.split('?')[0];
|
||||
// 2. split path into segments and filter out empty segments
|
||||
const segments = pathWithoutQuery.split('/').filter(Boolean);
|
||||
|
||||
// If this is a preset path, return the alias (last segment)
|
||||
if (segments[0] === 'preset' && segments.length > 1) {
|
||||
return segments[1];
|
||||
}
|
||||
|
||||
// Otherwise return the last segment (view name)
|
||||
return segments[segments.length - 1] || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles generating a path and search parameters from a preset
|
||||
* This is done when we want to keep the current navigation and unwrap the search params
|
||||
*/
|
||||
export function generatePathFromPreset(
|
||||
target: Omit<OntimeView, 'editor'>,
|
||||
search: string,
|
||||
alias: string,
|
||||
locked: string | null,
|
||||
locked: boolean,
|
||||
token: string | null,
|
||||
): string {
|
||||
const path = resolvePath(`${target}?${search}`);
|
||||
@@ -93,7 +103,7 @@ export function generatePathFromPreset(
|
||||
|
||||
// maintain params from the URL search feature
|
||||
if (locked) {
|
||||
searchParams.set('locked', locked);
|
||||
searchParams.set('n', '1');
|
||||
}
|
||||
|
||||
if (token) {
|
||||
@@ -106,28 +116,27 @@ export function generatePathFromPreset(
|
||||
|
||||
/**
|
||||
* Utility checks if two paths are equivalent
|
||||
* Considers the edge cases for url sharing where a path may contain extra arguments from the alias
|
||||
* - token
|
||||
* - locked
|
||||
* For preset paths, only compares the path (since params are stored in session)
|
||||
* For regular paths, compares path and search params (ignoring token)
|
||||
*/
|
||||
export function arePathsEquivalent(currentPath: string, newPath: string): boolean {
|
||||
const currentUrl = new URL(currentPath, document.location.origin);
|
||||
const newUrl = new URL(newPath, document.location.origin);
|
||||
|
||||
// check path
|
||||
// For preset paths, only compare the path
|
||||
if (currentUrl.pathname.startsWith('/preset/') || newUrl.pathname.startsWith('/preset/')) {
|
||||
return currentUrl.pathname === newUrl.pathname;
|
||||
}
|
||||
|
||||
// For regular paths, compare path and search params (ignoring token)
|
||||
if (currentUrl.pathname !== newUrl.pathname) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// check search params
|
||||
// if the params match, we dont need further checks
|
||||
if (currentUrl.searchParams.toString() === newUrl.searchParams.toString()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// if there is no match, we check the edge cases for the url sharing feature
|
||||
currentUrl.searchParams.delete('token');
|
||||
currentUrl.searchParams.delete('locked');
|
||||
currentUrl.searchParams.delete('n');
|
||||
newUrl.searchParams.delete('token');
|
||||
newUrl.searchParams.delete('n');
|
||||
|
||||
return currentUrl.searchParams.toString() === newUrl.searchParams.toString();
|
||||
}
|
||||
@@ -143,26 +152,28 @@ export function generateUrlPresetOptions(alias: string, userUrl: string): URLPre
|
||||
}
|
||||
|
||||
const url = new URL(sanitisedUrl);
|
||||
const target = extractLastSegment(url.pathname);
|
||||
const path = getCurrentPath(url);
|
||||
|
||||
if (target === 'editor' || !Object.values(OntimeView).includes(target as OntimeView)) {
|
||||
throw new Error(`Invalid target view: ${target}`);
|
||||
if (!isPresettableView(path)) {
|
||||
throw new Error(`Invalid target view: ${path}`);
|
||||
}
|
||||
|
||||
return {
|
||||
alias,
|
||||
target,
|
||||
target: path,
|
||||
search: url.searchParams.toString(),
|
||||
enabled: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* the path can contain the stage hash
|
||||
* "/team-hash/timer" or "/timer"
|
||||
* we need to extract ontime view it targets
|
||||
*/
|
||||
function extractLastSegment(pathname: string): string {
|
||||
const segments = pathname.split('/').filter(Boolean);
|
||||
return segments[segments.length - 1] || '';
|
||||
function isPresettableView(view: string): view is OntimeViewPresettable {
|
||||
return view !== OntimeView.Editor && Object.values(OntimeView).includes(view as OntimeView);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if current location is a preset path
|
||||
*/
|
||||
export function isPresetPath(location: Path): boolean {
|
||||
const segments = location.pathname.split('/').filter(Boolean);
|
||||
return segments[0] === 'preset';
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { AppMode } from '../ontimeConfig';
|
||||
|
||||
declare module '*.scss' {
|
||||
const content: Record<string, string>;
|
||||
export default content;
|
||||
@@ -26,6 +28,8 @@ declare global {
|
||||
* - `handleUpdateTimer` callback to update the timer for a specific event
|
||||
* - `options-showDelayedTimes` whether to show or hide delayed times
|
||||
* - `options-hideTableSeconds` whether to hide seconds in the table
|
||||
* - `options-hideIndexColumn` whether to hide the index column
|
||||
* - `options-cuesheetMode` run or edit mode
|
||||
*
|
||||
* And metadata specific for each column
|
||||
* - `canWrite` whether the user can write to this column
|
||||
@@ -39,6 +43,8 @@ declare module '@tanstack/react-table' {
|
||||
options: {
|
||||
showDelayedTimes: boolean;
|
||||
hideTableSeconds: boolean;
|
||||
hideIndexColumn: boolean;
|
||||
cuesheetMode: AppMode;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -74,3 +74,26 @@ function resolveBaseURI(): string {
|
||||
|
||||
return base;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a session scope for the session
|
||||
*/
|
||||
export const sessionScope = resolveSessionScope();
|
||||
export const getIsViewLocked = () => window.location.search.includes('n=1');
|
||||
|
||||
/**
|
||||
* The session scope is read from the cookie and will only exist if the app is password protected
|
||||
*/
|
||||
function resolveSessionScope() {
|
||||
const tokenCookie = document.cookie.split('; ').find((cookie) => cookie.startsWith('token='));
|
||||
|
||||
if (tokenCookie) {
|
||||
try {
|
||||
const { scope } = JSON.parse(tokenCookie.split('=')[1]);
|
||||
return scope;
|
||||
} catch {
|
||||
return 'rw';
|
||||
}
|
||||
}
|
||||
return 'rw';
|
||||
}
|
||||
|
||||
@@ -132,6 +132,7 @@ $inner-padding: 1rem;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import ViewNavigationMenu from '../../common/components/navigation-menu/ViewNavigationMenu';
|
||||
import ProtectRoute from '../../common/components/protect-route/ProtectRoute';
|
||||
import { getIsViewLocked } from '../../externals';
|
||||
|
||||
import Operator from './Operator';
|
||||
|
||||
export default function OperatorExport() {
|
||||
return (
|
||||
<ProtectRoute permission='operator'>
|
||||
<ViewNavigationMenu isLockable />
|
||||
<ViewNavigationMenu isViewLocked={getIsViewLocked()} />
|
||||
<Operator />
|
||||
</ProtectRoute>
|
||||
);
|
||||
|
||||
@@ -17,7 +17,7 @@ function RundownMenu() {
|
||||
|
||||
const clearSelectedEvents = useEventSelection((state) => state.clearSelectedEvents);
|
||||
const [editorMode] = useSessionStorage({
|
||||
key: sessionKeys.cuesheetMode,
|
||||
key: sessionKeys.editorMode,
|
||||
defaultValue: AppMode.Edit,
|
||||
});
|
||||
const { deleteAllEntries } = useEntryActions();
|
||||
|
||||
@@ -16,4 +16,16 @@
|
||||
.copiableLink {
|
||||
user-select: text;
|
||||
color: $ui-white;
|
||||
}
|
||||
white-space: nowrap;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.shareInline {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 172px;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.end {
|
||||
padding-right: 2rem;
|
||||
}
|
||||
|
||||
@@ -1,70 +1,157 @@
|
||||
import { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useRef, useState } from 'react';
|
||||
import { FieldErrors, useForm } from 'react-hook-form';
|
||||
import QRCode from 'react-qr-code';
|
||||
import { OntimeView, URLPreset } from 'ontime-types';
|
||||
import { generateId } from 'ontime-utils';
|
||||
|
||||
import { generateUrl } from '../../common/api/session';
|
||||
import { maybeAxiosError } from '../../common/api/utils';
|
||||
import Button from '../../common/components/buttons/Button';
|
||||
import CopyTag from '../../common/components/copy-tag/CopyTag';
|
||||
import Info from '../../common/components/info/Info';
|
||||
import Input from '../../common/components/input/input/Input';
|
||||
import Select from '../../common/components/select/Select';
|
||||
import Switch from '../../common/components/switch/Switch';
|
||||
import { useUpdateUrlPreset } from '../../common/hooks-query/useUrlPresets';
|
||||
import copyToClipboard from '../../common/utils/copyToClipboard';
|
||||
import { preventEscape } from '../../common/utils/keyEvent';
|
||||
import { linkToOtherHost } from '../../common/utils/linkUtils';
|
||||
import { currentHostName, isOntimeCloud, serverURL } from '../../externals';
|
||||
import { isUrlSafe } from '../../common/utils/regex';
|
||||
import { isOntimeCloud, serverURL } from '../../externals';
|
||||
import * as Panel from '../app-settings/panel-utils/PanelUtils';
|
||||
|
||||
import CuesheetLinkOptions from './composite/CuesheetLinkOptions';
|
||||
|
||||
import style from './GenerateLinkForm.module.scss';
|
||||
|
||||
interface GenerateLinkFormProps {
|
||||
hostOptions: { value: string; label: string }[];
|
||||
pathOptions: { value: string; label: string }[];
|
||||
pathOptions: { value: OntimeView | string; label: string }[];
|
||||
presets: URLPreset[];
|
||||
isLockedToView?: boolean;
|
||||
}
|
||||
|
||||
interface GenerateLinkFormOptions {
|
||||
type GenericLinkOptions = {
|
||||
baseUrl: string;
|
||||
path: string;
|
||||
lock: boolean;
|
||||
path: OntimeView | string; // we use empty string for Companion view
|
||||
authenticate: boolean;
|
||||
}
|
||||
lockConfig: boolean;
|
||||
lockNav: boolean;
|
||||
};
|
||||
|
||||
type CuesheetLinkOptions = GenericLinkOptions & {
|
||||
path: OntimeView.Cuesheet;
|
||||
|
||||
alias: string;
|
||||
options: {
|
||||
read?: string;
|
||||
write?: string;
|
||||
};
|
||||
};
|
||||
|
||||
type GenerateLinkFormOptions = GenericLinkOptions | CuesheetLinkOptions;
|
||||
|
||||
type GenerateLinkState = 'pending' | 'loading' | 'success' | 'error';
|
||||
|
||||
export default function GenerateLinkForm({ hostOptions, pathOptions, isLockedToView }: GenerateLinkFormProps) {
|
||||
export default function GenerateLinkForm({ hostOptions, pathOptions, presets, isLockedToView }: GenerateLinkFormProps) {
|
||||
const [formState, setFormState] = useState<GenerateLinkState>('pending');
|
||||
const [url, setUrl] = useState(serverURL);
|
||||
const cuesheetReadRef = useRef<HTMLInputElement>(null);
|
||||
const cuesheetWriteRef = useRef<HTMLInputElement>(null);
|
||||
const generatedAlias = useRef<string>(`cuesheet-${generateId()}`);
|
||||
|
||||
const { addPreset } = useUpdateUrlPreset();
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
setError,
|
||||
watch,
|
||||
setValue,
|
||||
formState: { errors },
|
||||
reset,
|
||||
register,
|
||||
formState: { errors, isDirty },
|
||||
} = useForm<GenerateLinkFormOptions>({
|
||||
mode: 'onChange',
|
||||
defaultValues: {
|
||||
baseUrl: currentHostName,
|
||||
path: isLockedToView ? pathOptions[0].value : 'timer',
|
||||
lock: false,
|
||||
baseUrl: serverURL,
|
||||
path: isLockedToView ? pathOptions[0].value : OntimeView.Timer,
|
||||
authenticate: false,
|
||||
},
|
||||
resetOptions: {
|
||||
keepDirtyValues: true,
|
||||
lockConfig: false,
|
||||
lockNav: false,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* If the user is generating a link to the cuesheet we gather extra options
|
||||
* The extra options are saved into a URL preset which we then request a share link for
|
||||
*/
|
||||
const createPresetFromOptions = async (
|
||||
alias: string,
|
||||
options: Required<CuesheetLinkOptions['options']>,
|
||||
): Promise<URLPreset | undefined> => {
|
||||
if (options.read === '-') {
|
||||
throw new Error('Cannot create a share with no read permissions');
|
||||
}
|
||||
const presets = await addPreset({
|
||||
target: OntimeView.Cuesheet,
|
||||
enabled: true,
|
||||
alias,
|
||||
search: '',
|
||||
options: {
|
||||
read: options.read,
|
||||
write: options.write,
|
||||
},
|
||||
});
|
||||
return presets.find((preset) => preset.alias === alias);
|
||||
};
|
||||
|
||||
const onSubmit = async (options: GenerateLinkFormOptions) => {
|
||||
try {
|
||||
setFormState('loading');
|
||||
const baseUrl = linkToOtherHost(options.baseUrl);
|
||||
const url = await generateUrl(baseUrl, options.path, options.lock, options.authenticate);
|
||||
await copyToClipboard(url);
|
||||
setUrl(url);
|
||||
if (options.path === OntimeView.Cuesheet) {
|
||||
const urlPreset = await createPresetFromOptions((options as CuesheetLinkOptions).alias, {
|
||||
read: cuesheetReadRef.current?.value ?? 'full',
|
||||
write: cuesheetWriteRef.current?.value ?? 'full',
|
||||
});
|
||||
|
||||
if (!urlPreset) {
|
||||
throw new Error('Failed to create URL preset for Cuesheet');
|
||||
}
|
||||
|
||||
const url = await generateUrl({
|
||||
baseUrl: options.baseUrl,
|
||||
path: options.path,
|
||||
authenticate: options.authenticate,
|
||||
lockConfig: options.lockConfig,
|
||||
lockNav: options.lockNav,
|
||||
preset: urlPreset.alias,
|
||||
});
|
||||
await copyToClipboard(url);
|
||||
setUrl(url);
|
||||
} else {
|
||||
const presetPath = options.path.startsWith('preset-') ? options.path.replace('preset-', '') : undefined;
|
||||
const path = presetPath ? presets.find((preset) => preset.alias === presetPath)?.target : options.path;
|
||||
if (!path) {
|
||||
throw new Error(`Could not resolve preset: ${path}`);
|
||||
}
|
||||
|
||||
const url = await generateUrl({
|
||||
baseUrl: linkToOtherHost(options.baseUrl),
|
||||
path,
|
||||
authenticate: options.authenticate,
|
||||
lockConfig: options.lockConfig,
|
||||
lockNav: options.lockNav,
|
||||
preset: presetPath,
|
||||
});
|
||||
|
||||
await copyToClipboard(url);
|
||||
setUrl(url);
|
||||
}
|
||||
reset(options, {
|
||||
keepValues: true,
|
||||
keepDirty: false,
|
||||
});
|
||||
setFormState('success');
|
||||
setTimeout(() => {
|
||||
setFormState('pending');
|
||||
}, 4000);
|
||||
} catch (error) {
|
||||
const message = maybeAxiosError(error);
|
||||
setError('root', { message });
|
||||
@@ -72,70 +159,113 @@ export default function GenerateLinkForm({ hostOptions, pathOptions, isLockedToV
|
||||
}
|
||||
};
|
||||
|
||||
const canSubmit = isDirty || formState !== 'success';
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} onKeyDown={(event) => preventEscape(event)}>
|
||||
{errors.root && <Panel.Error>{errors.root.message}</Panel.Error>}
|
||||
{!isLockedToView ? (
|
||||
{!isLockedToView && (
|
||||
<Info>You can generate a link to share with your team or to use in automation (such as companion).</Info>
|
||||
) : (
|
||||
<Info>You can generate a link to share with your team</Info>
|
||||
)}
|
||||
<Panel.ListGroup>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
title='Host IP'
|
||||
description={`Which IP address will be used${isOntimeCloud ? ' (not applicable in Ontime Cloud)' : ''}`}
|
||||
/>
|
||||
<Select
|
||||
disabled={isOntimeCloud}
|
||||
options={hostOptions}
|
||||
value={watch('baseUrl')}
|
||||
onValueChange={(value) => setValue('baseUrl', value)}
|
||||
/>
|
||||
</Panel.ListItem>
|
||||
{isLockedToView ? (
|
||||
<input type='hidden' value={watch('path')} />
|
||||
) : (
|
||||
<Panel.ListItem>
|
||||
<Panel.Field title='Ontime view' description='Which view or preset will the link point to' />
|
||||
<Select options={pathOptions} value={watch('path')} onValueChange={(value) => setValue('path', value)} />
|
||||
</Panel.ListItem>
|
||||
)}
|
||||
<div className={style.shareInline}>
|
||||
<div className={style.column}>
|
||||
<Panel.ListGroup>
|
||||
{isOntimeCloud ? (
|
||||
<input hidden readOnly name='baseUrl' value={serverURL} />
|
||||
) : (
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
title='Host IP'
|
||||
description={`Which IP address will be used${isOntimeCloud ? ' (not applicable in Ontime Cloud)' : ''}`}
|
||||
/>
|
||||
<Select
|
||||
options={hostOptions}
|
||||
value={watch('baseUrl')}
|
||||
onValueChange={(value) => setValue('baseUrl', value)}
|
||||
/>
|
||||
</Panel.ListItem>
|
||||
)}
|
||||
{isLockedToView ? (
|
||||
<input type='hidden' value={watch('path')} />
|
||||
) : (
|
||||
<Panel.ListItem>
|
||||
<Panel.Field title='Ontime view' description='Which view or preset will the link point to' />
|
||||
<Select
|
||||
options={pathOptions}
|
||||
value={watch('path')}
|
||||
onValueChange={(value) => setValue('path', value, { shouldDirty: true })}
|
||||
/>
|
||||
</Panel.ListItem>
|
||||
)}
|
||||
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
title='Lock navigation'
|
||||
description='Prevent showing navigation (will only work for non production URLs)'
|
||||
/>
|
||||
<Switch
|
||||
size='large'
|
||||
name='lock'
|
||||
checked={watch('lock')}
|
||||
onCheckedChange={(checked) => setValue('lock', checked)}
|
||||
/>
|
||||
</Panel.ListItem>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field title='Authenticate' description='Whether the URL should be pre-authenticated' />
|
||||
<Switch
|
||||
size='large'
|
||||
name='authenticate'
|
||||
checked={watch('authenticate')}
|
||||
onCheckedChange={(checked) => setValue('authenticate', checked)}
|
||||
/>
|
||||
</Panel.ListItem>
|
||||
</Panel.ListGroup>
|
||||
<Panel.ListGroup>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field title='Generate link' description='Fill form and generate link and QR code' />
|
||||
<Button variant='primary' loading={formState === 'loading'} type='submit' style={{ alignSelf: 'end' }}>
|
||||
{formState === 'success' ? 'Link copied to clipboard!' : 'Update share link'}
|
||||
</Button>
|
||||
<div className={style.column}>
|
||||
<QRCode size={172} value={url} className={style.qrCode} />
|
||||
<div className={style.copiableLink}>{url}</div>
|
||||
{watch('path') === OntimeView.Cuesheet && (
|
||||
<>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
title='Preset alias'
|
||||
description='The name of the preset we will create to hold this options'
|
||||
error={(errors as FieldErrors<CuesheetLinkOptions>).alias?.message}
|
||||
/>
|
||||
<Input
|
||||
defaultValue={generatedAlias.current}
|
||||
{...register('alias', {
|
||||
required: 'Alias cannot be empty and must be unique',
|
||||
pattern: {
|
||||
value: isUrlSafe,
|
||||
message: 'Field can only contain URL safe characters (a-z, 0-9, _ and -)',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
</Panel.ListItem>
|
||||
<CuesheetLinkOptions readRef={cuesheetReadRef} writeRef={cuesheetWriteRef} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<Panel.ListItem>
|
||||
<Panel.Field title='Lock navigation' description='Whether to hide the navigation menu' />
|
||||
<Switch
|
||||
size='large'
|
||||
name='lockNav'
|
||||
checked={watch('lockNav')}
|
||||
onCheckedChange={(checked) => setValue('lockNav', checked, { shouldDirty: true })}
|
||||
/>
|
||||
</Panel.ListItem>
|
||||
{watch('path') !== OntimeView.Cuesheet && (
|
||||
<Panel.ListItem>
|
||||
<Panel.Field title='Lock configuration' description='Whether to hide the configuration panel' />
|
||||
<Switch
|
||||
size='large'
|
||||
name='lockConfig'
|
||||
checked={watch('lockConfig')}
|
||||
onCheckedChange={(checked) => setValue('lockConfig', checked, { shouldDirty: true })}
|
||||
/>
|
||||
</Panel.ListItem>
|
||||
)}
|
||||
<Panel.ListItem>
|
||||
<Panel.Field title='Authenticate' description='Whether the URL should be pre-authenticated' />
|
||||
<Switch
|
||||
size='large'
|
||||
name='authenticate'
|
||||
checked={watch('authenticate')}
|
||||
onCheckedChange={(checked) => setValue('authenticate', checked, { shouldDirty: true })}
|
||||
/>
|
||||
</Panel.ListItem>
|
||||
</Panel.ListGroup>
|
||||
<Panel.Error>{errors.root?.message}</Panel.Error>
|
||||
<Panel.InlineElements align='end' className={style.end}>
|
||||
<Button type='submit' variant={canSubmit ? 'primary' : 'subtle'} loading={formState === 'loading'}>
|
||||
{canSubmit ? 'Create share link' : 'Link copied to clipboard!'}
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
</div>
|
||||
<Panel.Section className={style.column}>
|
||||
<Panel.Description>Share this link</Panel.Description>
|
||||
<QRCode size={172} value={url} className={style.qrCode} />
|
||||
<div className={style.copiableLink} data-testid='copy-link'>
|
||||
{url}
|
||||
</div>
|
||||
</Panel.ListItem>
|
||||
</Panel.ListGroup>
|
||||
<CopyTag copyValue={url}>Copy link</CopyTag>
|
||||
</Panel.Section>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 <GenerateLinkForm hostOptions={hostOptions} pathOptions={pathOptions} isLockedToView={Boolean(lockedPath)} />;
|
||||
return (
|
||||
<GenerateLinkForm
|
||||
hostOptions={hostOptions}
|
||||
pathOptions={pathOptions}
|
||||
presets={urlPresetData}
|
||||
isLockedToView={Boolean(lockedPath)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<HTMLInputElement | null>;
|
||||
writeRef?: RefObject<HTMLInputElement | null>;
|
||||
}
|
||||
|
||||
export default function CuesheetLinkOptions({ readRef, writeRef }: CuesheetLinkOptionsProps) {
|
||||
const { data } = useCustomFields();
|
||||
const customFieldColumns = useMemo(() => makeCuesheetCustomColumns(data), [data]);
|
||||
|
||||
const [readPermissions, setReadPermissions] = useState<AccessMode>('full');
|
||||
const [writePermissions, setWritePermissions] = useState<AccessMode>('full');
|
||||
|
||||
const [readSwitches, setReadSwitches] = useState<Record<string, boolean>>(() => {
|
||||
const initialState: Record<string, boolean> = {};
|
||||
[...cuesheetDefaultColumns, ...customFieldColumns].forEach((column) => {
|
||||
initialState[column.value] = true;
|
||||
});
|
||||
return initialState;
|
||||
});
|
||||
|
||||
const [writeSwitches, setWriteSwitches] = useState<Record<string, boolean>>(() => {
|
||||
const initialState: Record<string, boolean> = {};
|
||||
[...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 (
|
||||
<Panel.Indent>
|
||||
<input name='read' hidden readOnly ref={readRef} value={getReadPermissions() || '-'} />
|
||||
<input name='write' hidden readOnly ref={writeRef} value={getWritePermissions() || '-'} />
|
||||
<div>
|
||||
<Panel.Field title='Access mode' description='Which parts of the data will the link give access to' />
|
||||
<div>
|
||||
<RadioGroup
|
||||
value={writePermissions}
|
||||
onValueChange={handleWriteModeChange}
|
||||
orientation='horizontal'
|
||||
items={[
|
||||
{ value: 'full', label: 'Full write (edit all existing and future columns)' },
|
||||
{ value: 'custom', label: 'Custom write' },
|
||||
]}
|
||||
/>
|
||||
<RadioGroup
|
||||
value={readPermissions}
|
||||
onValueChange={handleReadModeChange}
|
||||
orientation='horizontal'
|
||||
disabled={writePermissions === 'full'}
|
||||
items={[
|
||||
{ value: 'full', label: 'Full read (view all existing and future columns)' },
|
||||
{ value: 'custom', label: 'Custom read' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={style.twoCols}>
|
||||
<div className={style.grid}>
|
||||
<Panel.Description>Ontime columns</Panel.Description>
|
||||
<Panel.Description>Read</Panel.Description>
|
||||
<Panel.Description>Write</Panel.Description>
|
||||
{cuesheetDefaultColumns.map((column) => (
|
||||
<Fragment key={column.value}>
|
||||
<div>{column.label}</div>
|
||||
<Switch
|
||||
checked={Boolean(readSwitches[column.value])}
|
||||
onCheckedChange={(value: boolean) => handleSwitchChange(column.value, 'read', value)}
|
||||
disabled={readPermissions === 'full' || writePermissions === 'full'}
|
||||
data-testid={`read-${column.value}`}
|
||||
/>
|
||||
<Switch
|
||||
checked={Boolean(writeSwitches[column.value])}
|
||||
onCheckedChange={(value: boolean) => handleSwitchChange(column.value, 'write', value)}
|
||||
disabled={writePermissions === 'full'}
|
||||
data-testid={`write-${column.value}`}
|
||||
/>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
{customFieldColumns.length > 0 && (
|
||||
<div className={style.grid}>
|
||||
<Panel.Description>Custom fields</Panel.Description>
|
||||
<Panel.Description>Read</Panel.Description>
|
||||
<Panel.Description>Write</Panel.Description>
|
||||
{customFieldColumns.map((column) => (
|
||||
<Fragment key={column.value}>
|
||||
{column.label}
|
||||
<Switch
|
||||
checked={Boolean(readSwitches[column.value])}
|
||||
onCheckedChange={(value: boolean) => handleSwitchChange(column.value, 'read', value)}
|
||||
disabled={readPermissions === 'full' || writePermissions === 'full'}
|
||||
data-testid={`read-${column.value}`}
|
||||
/>
|
||||
<Switch
|
||||
checked={Boolean(writeSwitches[column.value])}
|
||||
onCheckedChange={(value: boolean) => handleSwitchChange(column.value, 'write', value)}
|
||||
disabled={writePermissions === 'full'}
|
||||
data-testid={`write-${column.value}`}
|
||||
/>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Panel.Indent>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import EmptyImage from '../../../assets/images/empty.svg?react';
|
||||
|
||||
import style from './NotFound.module.scss';
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className={style.notFound}>
|
||||
<EmptyImage />
|
||||
<h1>Not found</h1>
|
||||
<div>
|
||||
The page you are after was not found.
|
||||
<br />
|
||||
It may have moved or your URL may be incorrect.
|
||||
<br />
|
||||
Double check the URL and try again.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<NavigationMenu isOpen={isMenuOpen} onClose={menuHandler.close} />
|
||||
<CuesheetEditModal />
|
||||
<div className={styles.tableWrapper} data-testid='cuesheet'>
|
||||
<CuesheetOverview>
|
||||
{!isViewLocked && (
|
||||
{!isLocked && (
|
||||
<IconButton aria-label='Toggle navigation' variant='subtle-white' size='xlarge' onClick={menuHandler.open}>
|
||||
<IoApps />
|
||||
</IconButton>
|
||||
|
||||
@@ -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 (
|
||||
<CuesheetDnd columns={columns}>
|
||||
{isLoading ? <EmptyPage text='Loading...' /> : <CuesheetTable data={flatRundown} columns={columns} />}
|
||||
{isLoading ? (
|
||||
<EmptyPage text='Loading...' />
|
||||
) : (
|
||||
<CuesheetTable data={flatRundown} columns={columns} cuesheetMode={cuesheetMode} />
|
||||
)}
|
||||
</CuesheetDnd>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<OntimeEntry>[];
|
||||
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) {
|
||||
/>
|
||||
<div className={style.cuesheetContainer} ref={scrollRef}>
|
||||
<table className={style.cuesheet} id='cuesheet' style={{ ...columnSizeVars }} {...listeners}>
|
||||
<CuesheetHeader headerGroups={headerGroups} />
|
||||
<CuesheetHeader headerGroups={headerGroups} cuesheetMode={cuesheetMode} />
|
||||
{table.getState().columnSizingInfo.isResizingColumn ? (
|
||||
<MemoisedBody rowModel={rowModel} selectedRef={selectedRef} table={table} />
|
||||
) : (
|
||||
|
||||
@@ -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<AppMode>({
|
||||
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) {
|
||||
|
||||
+1
-1
@@ -113,7 +113,7 @@ export default function CuesheetBody({ rowModel, selectedRef, table }: CuesheetB
|
||||
if (entry.parent) {
|
||||
const rundown = queryClient.getQueryData<Rundown>(RUNDOWN);
|
||||
const parentEntry = rundown?.entries[entry.parent];
|
||||
parentBgColour = (parentEntry as OntimeBlock).colour ?? null;
|
||||
parentBgColour = (parentEntry as OntimeBlock | undefined)?.colour ?? null;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
+3
-7
@@ -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<OntimeEntry>[];
|
||||
cuesheetMode: AppMode;
|
||||
}
|
||||
|
||||
export default function CuesheetHeader({ headerGroups }: CuesheetHeaderProps) {
|
||||
export default function CuesheetHeader({ headerGroups, cuesheetMode }: CuesheetHeaderProps) {
|
||||
const hideIndexColumn = usePersistedCuesheetOptions((state) => state.hideIndexColumn);
|
||||
const [cuesheetMode] = useSessionStorage<AppMode>({
|
||||
key: sessionKeys.cuesheetMode,
|
||||
defaultValue: AppMode.Edit,
|
||||
});
|
||||
|
||||
return (
|
||||
<thead className={style.tableHeader}>
|
||||
|
||||
@@ -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<AppMode>({
|
||||
key: sessionKeys.cuesheetMode,
|
||||
defaultValue: AppMode.Edit,
|
||||
});
|
||||
const { cuesheetMode, hideIndexColumn } = table.options.meta?.options ?? {
|
||||
cuesheetMode: AppMode.Edit,
|
||||
hideIndexColumn: false,
|
||||
};
|
||||
|
||||
const ownRef = useRef<HTMLTableRowElement>(null);
|
||||
|
||||
const isVisible = useVisibleRowsStore((state) => state.visibleRows.has(rowId));
|
||||
|
||||
const openMenu = useCuesheetTableMenu((store) => store.openMenu);
|
||||
|
||||
// register this row with the intersection observer
|
||||
|
||||
+6
-8
@@ -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<AppMode>({
|
||||
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 (
|
||||
|
||||
+94
-66
@@ -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<OntimeEntry, unknow
|
||||
* we cant use the createColumnHelper() because we have custom logic for rendering the cells
|
||||
* This means that the display columns: index and action are added inline by the row components
|
||||
*/
|
||||
export function makeCuesheetColumns(customFields: CustomFields, cuesheetMode: AppMode): ColumnDef<OntimeEntry>[] {
|
||||
export function makeCuesheetColumns(
|
||||
customFields: CustomFields,
|
||||
cuesheetMode: AppMode,
|
||||
preset: URLPreset | undefined,
|
||||
): ColumnDef<OntimeEntry>[] {
|
||||
const columnsDef: ColumnDef<OntimeEntry>[] = [];
|
||||
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<string>();
|
||||
const canReadKeys = preset?.options?.read ? new Set(preset.options.read.split(',')) : new Set<string>();
|
||||
|
||||
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),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
+16
-3
@@ -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}
|
||||
|
||||
+4
-1
@@ -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 ? <GenerateLinkFormExport lockedPath={{ value: 'cuesheet', label: 'Cuesheet' }} /> : null
|
||||
showModalContent ? (
|
||||
<GenerateLinkFormExport lockedPath={{ value: OntimeView.Cuesheet, label: 'Cuesheet' }} />
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</>
|
||||
|
||||
+14
-6
@@ -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}
|
||||
/>
|
||||
|
||||
<ToggleGroup value={[cuesheetMode]} onValueChange={toggleCuesheetMode} className={cx([style.group, style.apart])}>
|
||||
<Toolbar.Button render={<Toggle />} value={AppMode.Run} className={style.radioButton}>
|
||||
Run
|
||||
@@ -64,8 +68,12 @@ export default function CuesheetTableSettings({
|
||||
</Toolbar.Button>
|
||||
</ToggleGroup>
|
||||
|
||||
<Editor.Separator orientation='vertical' />
|
||||
<CuesheetShareModal />
|
||||
{canShare && (
|
||||
<>
|
||||
<Editor.Separator orientation='vertical' />
|
||||
<CuesheetShareModal />
|
||||
</>
|
||||
)}
|
||||
</Toolbar.Root>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<CuesheetOptions>()(
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
interface CuesheetPermissionsStore {
|
||||
canChangeMode: boolean;
|
||||
canCreateEntries: boolean;
|
||||
canEditEntries: boolean;
|
||||
canFlag: boolean;
|
||||
canShare: boolean;
|
||||
setPermissions: (permissions: Omit<CuesheetPermissionsStore, 'setPermissions'>) => void;
|
||||
}
|
||||
|
||||
export const useCuesheetPermissions = create<CuesheetPermissionsStore>((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,
|
||||
});
|
||||
},
|
||||
}));
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,12 +29,12 @@ router.get('/info', async (_req: Request, res: Response<GetInfo | ErrorResponse>
|
||||
|
||||
router.post('/url', validateGenerateUrl, (req: Request, res: Response<GetUrl | ErrorResponse>) => {
|
||||
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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -16,16 +16,17 @@ export function parseUrlPresets(data: Partial<DatabaseModel>, 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);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ router.post('/', validateNewPreset, async (req: Request, res: Response<URLPreset
|
||||
alias: req.body.alias,
|
||||
target: req.body.target,
|
||||
search: req.body.search,
|
||||
options: req.body.options,
|
||||
};
|
||||
|
||||
const currentPresets = getDataProvider().getUrlPresets();
|
||||
@@ -50,7 +51,7 @@ router.put('/:alias', validateUpdatePreset, async (req: Request, res: Response<U
|
||||
};
|
||||
|
||||
if (alias !== updatedPreset.alias) {
|
||||
throw new Error(`Changing alias is not permitted`);
|
||||
throw new Error('Changing alias is not permitted');
|
||||
}
|
||||
|
||||
const currentPresets = getDataProvider().getUrlPresets();
|
||||
|
||||
@@ -14,6 +14,10 @@ export const validateNewPreset = [
|
||||
body('target').isString().trim().notEmpty().isIn(Object.values(OntimeView)),
|
||||
body('search').isString().trim(),
|
||||
|
||||
// options are currently only provided for cuesheet presets
|
||||
body('options').optional().isObject(),
|
||||
body('options.*').isString().trim(),
|
||||
|
||||
requestValidationFunction,
|
||||
];
|
||||
|
||||
@@ -25,6 +29,10 @@ export const validateUpdatePreset = [
|
||||
body('target').isString().trim().notEmpty().isIn(Object.values(OntimeView)),
|
||||
body('search').isString().trim(),
|
||||
|
||||
// options are currently only provided for cuesheet presets
|
||||
body('options').optional().isObject(),
|
||||
body('options.*').isString().trim(),
|
||||
|
||||
requestValidationFunction,
|
||||
];
|
||||
|
||||
|
||||
@@ -121,8 +121,12 @@ export function authenticateSocket(_ws: WebSocket, req: IncomingMessage, next: (
|
||||
return next(new Error('Unauthorized'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a cookie with the provided token
|
||||
* We currently add a full 'rw' permission scope, this should be filtered when dealing with presets
|
||||
*/
|
||||
function setSessionCookie(res: Response, token: string) {
|
||||
res.cookie('token', token, {
|
||||
res.cookie('token', JSON.stringify({ token, scope: 'rw' }), {
|
||||
httpOnly: false, // allow websocket to access cookie
|
||||
secure: true,
|
||||
path: '/', // allow cookie to be accessed from any path
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { DatabaseModel, EndAction, SupportedEntry, TimeStrategy, TimerType } from 'ontime-types';
|
||||
import { DatabaseModel, EndAction, OntimeView, SupportedEntry, TimeStrategy, TimerType } from 'ontime-types';
|
||||
|
||||
export const demoDb: DatabaseModel = {
|
||||
rundowns: {
|
||||
@@ -325,10 +325,17 @@ export const demoDb: DatabaseModel = {
|
||||
warningColor: '#ffa528',
|
||||
},
|
||||
urlPresets: [
|
||||
{
|
||||
enabled: true,
|
||||
alias: 'clock',
|
||||
target: OntimeView.Timer,
|
||||
search:
|
||||
'showLeadingZeros=true&timerType=clock&hideClock=true&hideCards=true&hideProgress=true&hideMessage=true&hideSecondary=true&hideLogo=true',
|
||||
},
|
||||
{
|
||||
enabled: true,
|
||||
alias: 'minimal',
|
||||
target: 'timer',
|
||||
target: OntimeView.Timer,
|
||||
search:
|
||||
'hideclock=true&hidecards=true&hideprogress=true&hidemessage=true&hidesecondary=true&hidelogo=true&font=arial+black&keycolour=00ff00&textcolour=ffffff',
|
||||
},
|
||||
|
||||
@@ -47,6 +47,18 @@ test.describe('test view navigation feature', () => {
|
||||
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) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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/);
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
export type LinkOptions = {
|
||||
authenticate: boolean;
|
||||
lockConfig: boolean;
|
||||
lockNav: boolean;
|
||||
preset?: string;
|
||||
prefix?: string;
|
||||
hash?: string;
|
||||
};
|
||||
@@ -13,10 +13,25 @@ export enum OntimeView {
|
||||
ProjectInfo = 'info',
|
||||
}
|
||||
|
||||
export type URLPreset = {
|
||||
// presets cannot target the editor view
|
||||
target: Omit<OntimeView, 'editor'>;
|
||||
export type OntimeViewPresettable = Exclude<OntimeView, OntimeView.Editor>;
|
||||
|
||||
type BaseURLPreset = {
|
||||
target: OntimeViewPresettable;
|
||||
enabled: boolean;
|
||||
alias: string;
|
||||
search: string;
|
||||
options?: Record<string, string>;
|
||||
};
|
||||
|
||||
type CuesheetUrlPreset = {
|
||||
target: OntimeView.Cuesheet;
|
||||
enabled: boolean;
|
||||
alias: string;
|
||||
search: string;
|
||||
options: {
|
||||
read: string;
|
||||
write: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type URLPreset = BaseURLPreset | CuesheetUrlPreset;
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user