mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-06 07:53:54 +00:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3158311aa1 | |||
| 6b4d96dddd | |||
| dc3a32d2bc | |||
| bda01c886e | |||
| 93916653be | |||
| 40f8976350 | |||
| 78e905a735 | |||
| 126ff61738 | |||
| 1e63238cac | |||
| 6ffe34a642 | |||
| 69f276765b | |||
| 75554071a7 | |||
| a1fab10bfd | |||
| 5bc286145d | |||
| 44508ce8b4 | |||
| 90105a199a | |||
| 9618bec847 | |||
| 66a4e27fdd | |||
| ef906f43d7 | |||
| 93b2a1aece | |||
| 050629d281 | |||
| abe9012f20 |
@@ -43,12 +43,12 @@ Ontime is made by entertainment and broadcast engineers and used by
|
||||
- Houses of worship
|
||||
|
||||
## Main features
|
||||
- [x] **Multiplatform**: Available for Windows / MacOS, Linux. You can also self host with the docker image
|
||||
- [x] **In any device**: Ontime is available in the local network to any device with a browser, eg: tablets, mobile phones, laptops, signage, media servers...
|
||||
- [x] **Made for teams**: Ontime caters to different roles in your production team: directors, operators, backstage and front of house signage...
|
||||
- [x] **Delay workflows**: Manage and communicate runtime delays in real-time to your team
|
||||
- [x] **Automatable**: Ontime can be fully or partially controlled by an operator, or run standalone with the system clock
|
||||
- [x] **Focus on integrations**: Use one of the APIs provided (OSC, HTTP, Websocket) or the available [Companion module](https://bitfocus.io/connections/getontime-ontime) to integrate into your workflow (vMix, disguise, Qlab, OBS)
|
||||
- **Multiplatform**: Available as a Cloud service and for Windows, macOS, Linux, or self-hosted via Docker.
|
||||
- **In any device**: Ontime is available to any device with a browser, eg: tablets, mobile phones, laptops, signage, media servers...
|
||||
- **Team Collaboration**: Dedicated views for directors, operators, backstage, and signage.
|
||||
- **Real-Time Updates**: Manage and communicate runtime delays effortlessly.
|
||||
- **Automatable**: Ontime can be fully or partially controlled by an operator, or run standalone with the system clock
|
||||
- **Flexible Integrations**: Use one of the APIs provided (OSC, HTTP, Websocket) or the available [Companion module](https://bitfocus.io/connections/getontime-ontime) to integrate into your workflow (vMix, disguise, Qlab, OBS)
|
||||
|
||||
... and a lot more ...
|
||||
|
||||
@@ -79,75 +79,29 @@ Ontime is made by video engineers and entertainment technicians.
|
||||
|
||||
## Using Ontime
|
||||
|
||||
Ontime can be started by downloading the latest release for your platform. \
|
||||
Alternatively you can also use the docker image, available at [Docker Hub](https://hub.docker.com/r/getontime/ontime)
|
||||
### Getting started
|
||||
|
||||
Once installed and running, any device in the network has access to Ontime.
|
||||
The easiest way to start with Ontime is by leveraging our [Cloud service](https://getontime.no). \
|
||||
This will give you immediate access to running instances of Ontime which are available to share with anyone with an internet connection.
|
||||
|
||||
Ontime provides different screens which allow for different types of interactions with the data. These are called
|
||||
views. \
|
||||
Each view in Ontime focuses on empowering a specific role or achieving a particular task.
|
||||
Alternatively, you can run Ontime locally for free by downloading the latest release for your platform or using the docker image, available at [Docker Hub](https://hub.docker.com/r/getontime/ontime)
|
||||
|
||||
You can access the different views by reaching the ontime server, in your browser, at (_default port
|
||||
4001_) `http://localhost:4001` or `http://192.168.1.3:4001`
|
||||
|
||||
```
|
||||
For the backstage views
|
||||
-------------------------------------------------------------
|
||||
IP.ADDRESS:4001/timer > Presenter / Stage timer view
|
||||
IP.ADDRESS:4001/backstage > Stage Manager / Backstage view
|
||||
IP.ADDRESS:4001/countdown > Countdown to anything
|
||||
IP.ADDRESS:4001/studio > Studio Clock
|
||||
IP.ADDRESS:4001/timeline > Timeline
|
||||
```
|
||||
|
||||
```
|
||||
For production views
|
||||
-------------------------------------------------------------
|
||||
IP.ADDRESS:4001/editor > the control interface, same as the app
|
||||
IP.ADDRESS:4001/cuesheet > realtime cuesheets for collaboration
|
||||
IP.ADDRESS:4001/op > automated views for operators
|
||||
```
|
||||
Once installed and running, any device that shares the same network as Ontime will have access to Ontime.
|
||||
|
||||
More information is available [in our docs](https://docs.getontime.no)
|
||||
|
||||
## Roadmap
|
||||
|
||||
### Continued development
|
||||
## Continued development
|
||||
|
||||
Ontime is under active development. We continue adding and improving features in collaboration with users.
|
||||
|
||||
Have an idea? Reach out via [email](mail@getontime.no)
|
||||
or [open an issue](https://github.com/cpvalente/ontime/issues/new)
|
||||
|
||||
### Issues
|
||||
## Issues
|
||||
|
||||
We use Github's issue tracking for bug reporting and feature requests. \
|
||||
Found a bug? [Open an issue](https://github.com/cpvalente/ontime/issues/new).
|
||||
|
||||
#### Unsigned App
|
||||
|
||||
When installing the app you would see warning screens from the Operating System like:
|
||||
|
||||
in Windows
|
||||
|
||||
`Microsoft Defender SmartScreen prevented an unrecognised app from starting. Running this app might put your PC at risk.`
|
||||
|
||||
or in Linux
|
||||
|
||||
`Could Not Display "ontime-linux.AppImage`
|
||||
|
||||
We currently only sign MacOS releases. \
|
||||
Purchasing the certificates for both Mac and Windows would mean a recurrent expense which we are not able to cover.
|
||||
|
||||
You can circumvent this by allowing the execution of the app manually.
|
||||
|
||||
- In Windows: click `more` -> `Run Anyway`
|
||||
- In Linux: right-click the AppImage file: `Properties` -> `Permissions` -> `Allow Executing File as a Program`
|
||||
|
||||
If you have tips on how to improve this or would like to sponsor the code signing,
|
||||
please [open an issue](https://github.com/cpvalente/ontime/issues/new)
|
||||
|
||||
## Contributing
|
||||
|
||||
Looking to contribute? All types of help are appreciated, from coding to testing and feature specification.
|
||||
@@ -159,6 +113,7 @@ Information about the project setup can be found in the [development documentati
|
||||
## Links
|
||||
- [Ontime website](https://getontime.no)
|
||||
- [Documentation](https://docs.getontime.no)
|
||||
- [Video tutorials](https://www.youtube.com/@ontimeapp)
|
||||
- [Ontime discord server](https://discord.com/invite/eje3CSUEXm)
|
||||
|
||||
## License
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getontime/cli",
|
||||
"version": "4.0.0-alpha.0",
|
||||
"version": "4.0.0-alpha.1",
|
||||
"author": "Carlos Valente",
|
||||
"description": "Time keeping for live events",
|
||||
"repository": "https://github.com/cpvalente/ontime",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ontime-ui",
|
||||
"version": "4.0.0-alpha.0",
|
||||
"version": "4.0.0-alpha.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { apiEntryUrl } from './constants';
|
||||
const urlPresetsPath = `${apiEntryUrl}/url-presets`;
|
||||
|
||||
/**
|
||||
* HTTP request to retrieve aliases
|
||||
* HTTP request to retrieve all presets
|
||||
*/
|
||||
export async function getUrlPresets(): Promise<URLPreset[]> {
|
||||
const res = await axios.get(urlPresetsPath);
|
||||
@@ -14,8 +14,22 @@ export async function getUrlPresets(): Promise<URLPreset[]> {
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to mutate aliases
|
||||
* HTTP request to add a preset
|
||||
*/
|
||||
export async function postUrlPresets(data: URLPreset[]): Promise<URLPreset[]> {
|
||||
return axios.post(urlPresetsPath, data);
|
||||
export async function postUrlPreset(data: URLPreset): Promise<URLPreset[]> {
|
||||
return (await axios.post(urlPresetsPath, data)).data;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to edit a preset
|
||||
*/
|
||||
export async function putUrlPreset(alias: string, data: URLPreset): Promise<URLPreset[]> {
|
||||
return (await axios.put(`${urlPresetsPath}/${alias}`, data)).data;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to delete a preset
|
||||
*/
|
||||
export async function deleteUrlPreset(alias: string): Promise<URLPreset[]> {
|
||||
return (await axios.delete(`${urlPresetsPath}/${alias}`)).data;
|
||||
}
|
||||
|
||||
@@ -33,6 +33,20 @@ export function maybeAxiosError(error: unknown) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility unwrap a an instance of Error
|
||||
*/
|
||||
export function unwrapError(error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
} else {
|
||||
if (typeof error !== 'string') {
|
||||
return JSON.stringify(error);
|
||||
}
|
||||
return error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility unwraps a potential axios error and sends to logger
|
||||
* @param prepend
|
||||
|
||||
@@ -45,8 +45,8 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
|
||||
label: view.label,
|
||||
})),
|
||||
...enabledPresets.map((preset) => ({
|
||||
value: preset.pathAndParams,
|
||||
label: `Preset: ${preset.alias}`,
|
||||
value: preset.search,
|
||||
label: `URL Preset: ${preset.alias}`,
|
||||
})),
|
||||
];
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
.delaySymbol {
|
||||
svg {
|
||||
display: inline;
|
||||
vertical-align: middle;
|
||||
font-size: 1.5rem;
|
||||
color: $ontime-delay;
|
||||
margin: 0 auto;
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
|
||||
&:focus:not(:read-only) {
|
||||
background-color: $gray-1000;
|
||||
border: 1px solid $blue-500;
|
||||
outline: 2px solid $blue-500;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
|
||||
@@ -64,3 +64,8 @@
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.note {
|
||||
margin-left: auto;
|
||||
color: $white-20;
|
||||
}
|
||||
|
||||
@@ -57,9 +57,10 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
|
||||
Toggle Fullscreen
|
||||
{fullscreen ? <IoContract /> : <IoExpand />}
|
||||
</NavigationMenuItem>
|
||||
<NavigationMenuItem active={mirror} onClick={toggleMirror}>
|
||||
<NavigationMenuItem active={mirror} onClick={() => toggleMirror()}>
|
||||
Flip Screen
|
||||
<IoSwapVertical />
|
||||
{mirror && <span className={style.note}>Active</span>}
|
||||
</NavigationMenuItem>
|
||||
<NavigationMenuItem onClick={handlers.open}>Rename Client</NavigationMenuItem>
|
||||
|
||||
|
||||
@@ -11,8 +11,7 @@ interface EmptyProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function Empty(props: EmptyProps) {
|
||||
const { text, className, ...rest } = props;
|
||||
export default function Empty({ text, className, ...rest }: EmptyProps) {
|
||||
return (
|
||||
<div className={cx([style.emptyContainer, className])} {...rest}>
|
||||
<EmptyImage className={style.empty} />
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
.emptyContainer {
|
||||
width: 100%;
|
||||
color: $white-10;
|
||||
|
||||
.emptyCell {
|
||||
margin-inline: auto;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty {
|
||||
width: 100%;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.text {
|
||||
font-weight: 600;
|
||||
font-size: 2em;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import EmptyImage from '../../../assets/images/empty.svg?react';
|
||||
|
||||
import style from './EmptyTableBody.module.scss';
|
||||
|
||||
interface EmptyTableBodyProps {
|
||||
text: string;
|
||||
}
|
||||
|
||||
export default function EmptyTableBody({ text }: EmptyTableBodyProps) {
|
||||
return (
|
||||
<tbody className={style.emptyContainer}>
|
||||
<tr>
|
||||
<td colSpan={99} className={style.emptyCell}>
|
||||
<EmptyImage className={style.empty} />
|
||||
{text && <span className={style.text}>{text}</span>}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
);
|
||||
}
|
||||
-1
@@ -232,7 +232,6 @@ describe('getURLSearchParamsFromObj()', () => {
|
||||
bool2: 'on',
|
||||
};
|
||||
const result = getURLSearchParamsFromObj(params, mockOptionsWithBooleans);
|
||||
console.log('Result:', result.toString());
|
||||
expect(result.get('bool1')).toBe('false');
|
||||
expect(result.get('bool2')).toBe('true');
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { CustomFields } from 'ontime-types';
|
||||
import type { CustomFields, ProjectData } from 'ontime-types';
|
||||
|
||||
import type { SelectOption } from '../select/Select';
|
||||
|
||||
@@ -53,6 +53,18 @@ export function makeCustomFieldSelectOptions(customFields: CustomFields, filterI
|
||||
return options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates data for a select element that displays project custom data
|
||||
*/
|
||||
export function makeProjectDataOptions(projectData: ProjectData): SelectOption[] {
|
||||
return projectData.custom.map((entry, index) => {
|
||||
return {
|
||||
value: `${index}-${entry.title}`,
|
||||
label: entry.title,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
type ViewParamsObj = { [key: string]: string | FormDataEntryValue };
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
||||
import { PROJECT_DATA } from '../api/constants';
|
||||
import { getProjectData } from '../api/project';
|
||||
import { getProjectData, postProjectData } from '../api/project';
|
||||
import { projectDataPlaceholder } from '../models/ProjectData';
|
||||
|
||||
export default function useProjectData() {
|
||||
@@ -10,11 +10,25 @@ export default function useProjectData() {
|
||||
queryKey: PROJECT_DATA,
|
||||
queryFn: getProjectData,
|
||||
placeholderData: (previousData, _previousQuery) => previousData,
|
||||
retry: 5,
|
||||
retryDelay: (attempt) => attempt * 2500,
|
||||
refetchInterval: queryRefetchIntervalSlow,
|
||||
networkMode: 'always',
|
||||
});
|
||||
|
||||
return { data: data ?? projectDataPlaceholder, status, isFetching, isError, refetch };
|
||||
}
|
||||
|
||||
export function useUpdateProjectData() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const updateFn = useMutation({
|
||||
mutationFn: postProjectData,
|
||||
onSuccess: (newProjectData) => {
|
||||
queryClient.setQueryData(PROJECT_DATA, newProjectData);
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
updateProjectData: updateFn.mutateAsync,
|
||||
isMutating: updateFn.isPending,
|
||||
isMutatingError: updateFn.isError,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { URLPreset } from 'ontime-types';
|
||||
|
||||
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
||||
import { URL_PRESETS } from '../api/constants';
|
||||
import { getUrlPresets } from '../api/urlPresets';
|
||||
import { deleteUrlPreset, getUrlPresets, postUrlPreset, putUrlPreset } from '../api/urlPresets';
|
||||
|
||||
interface FetchProps {
|
||||
skip?: boolean;
|
||||
@@ -13,12 +14,42 @@ export default function useUrlPresets({ skip = false }: FetchProps = {}) {
|
||||
queryKey: URL_PRESETS,
|
||||
queryFn: getUrlPresets,
|
||||
placeholderData: (previousData, _previousQuery) => previousData,
|
||||
retry: 5,
|
||||
retryDelay: (attempt) => attempt * 2500,
|
||||
refetchInterval: queryRefetchIntervalSlow,
|
||||
networkMode: 'always',
|
||||
enabled: !skip,
|
||||
});
|
||||
|
||||
return { data: data ?? [], status, isError, refetch };
|
||||
}
|
||||
|
||||
export function useUpdateUrlPreset() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const addFn = useMutation({
|
||||
mutationFn: postUrlPreset,
|
||||
onSuccess: (newPresets) => {
|
||||
queryClient.setQueryData(URL_PRESETS, newPresets);
|
||||
},
|
||||
});
|
||||
|
||||
const updateFn = useMutation({
|
||||
mutationFn: ({ alias, data }: { alias: string; data: URLPreset }) => putUrlPreset(alias, data),
|
||||
onSuccess: (newPresets) => {
|
||||
queryClient.setQueryData(URL_PRESETS, newPresets);
|
||||
},
|
||||
});
|
||||
|
||||
const deleteFn = useMutation({
|
||||
mutationFn: deleteUrlPreset,
|
||||
onSuccess: (newPresets) => {
|
||||
queryClient.setQueryData(URL_PRESETS, newPresets);
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
addPreset: addFn.mutateAsync,
|
||||
updatePreset: (alias: string, data: URLPreset) => updateFn.mutateAsync({ alias, data }),
|
||||
deletePreset: deleteFn.mutateAsync,
|
||||
isMutating: addFn.isPending || updateFn.isPending || deleteFn.isPending,
|
||||
isMutationError: addFn.isError || updateFn.isError || deleteFn.isError,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -89,25 +89,17 @@ export const useEntryActions = () => {
|
||||
|
||||
// ************* CHECK OPTIONS specific to events
|
||||
if (isOntimeEvent(newEntry)) {
|
||||
// merge creation time options with event settings
|
||||
const applicationOptions = {
|
||||
after: options?.after,
|
||||
before: options?.before,
|
||||
lastEventId: options?.lastEventId,
|
||||
linkPrevious: options?.linkPrevious ?? linkPrevious,
|
||||
};
|
||||
|
||||
if (applicationOptions?.lastEventId) {
|
||||
if (options?.lastEventId) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- we know this is a value
|
||||
const rundownData = queryClient.getQueryData<Rundown>(RUNDOWN)!;
|
||||
const previousEvent = rundownData.entries[applicationOptions.lastEventId];
|
||||
const previousEvent = rundownData.entries[options?.lastEventId];
|
||||
if (isOntimeEvent(previousEvent)) {
|
||||
newEntry.timeStart = previousEvent.timeEnd;
|
||||
}
|
||||
}
|
||||
|
||||
// Override event with options from editor settings
|
||||
newEntry.linkStart = applicationOptions.linkPrevious;
|
||||
newEntry.linkStart = options?.linkPrevious ?? linkPrevious;
|
||||
|
||||
if (newEntry.duration === undefined && newEntry.timeEnd === undefined) {
|
||||
newEntry.duration = parseUserTime(defaultDuration);
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { resolvePath } from 'react-router-dom';
|
||||
|
||||
import { arePathsEquivalent, generatePathFromPreset, getRouteFromPreset, validateUrlPresetPath } from '../urlPresets';
|
||||
import {
|
||||
arePathsEquivalent,
|
||||
generatePathFromPreset,
|
||||
generateUrlPresetOptions,
|
||||
getRouteFromPreset,
|
||||
validateUrlPresetPath,
|
||||
} from '../urlPresets';
|
||||
|
||||
describe('validateUrlPresetPaths()', () => {
|
||||
test.each([
|
||||
@@ -27,7 +33,8 @@ describe('getRouteFromPreset()', () => {
|
||||
{
|
||||
enabled: true,
|
||||
alias: 'demopage',
|
||||
pathAndParams: '/timer?user=guest',
|
||||
target: 'timer',
|
||||
search: 'user=guest',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -76,14 +83,14 @@ describe('getRouteFromPreset()', () => {
|
||||
|
||||
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', (path, alias, expected) => {
|
||||
expect(generatePathFromPreset(path, alias, null, null)).toEqual(expected);
|
||||
['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);
|
||||
});
|
||||
|
||||
test('appends the feature params to the alias', () => {
|
||||
expect(generatePathFromPreset('timer?user=guest', 'demopage', 'true', '123')).toBe(
|
||||
expect(generatePathFromPreset('timer', 'user=guest', 'demopage', 'true', '123')).toBe(
|
||||
'timer?user=guest&alias=demopage&locked=true&token=123',
|
||||
);
|
||||
});
|
||||
@@ -106,3 +113,62 @@ describe('arePathsEquivalent()', () => {
|
||||
expect(arePathsEquivalent('timer?test=a&locked=true=token=123', 'timer?test=a')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateUrlPresetOptions', () => {
|
||||
it.each([
|
||||
[
|
||||
'cloud URL without protocol',
|
||||
'test',
|
||||
'www.getontime.no/timer?param1=value1¶m2=value2',
|
||||
{
|
||||
alias: 'test',
|
||||
target: 'timer',
|
||||
search: 'param1=value1¶m2=value2',
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
[
|
||||
'cloud URL',
|
||||
'test',
|
||||
'https://cloud.getontime.no/timer?param1=value1¶m2=value2',
|
||||
{
|
||||
alias: 'test',
|
||||
target: 'timer',
|
||||
search: 'param1=value1¶m2=value2',
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
[
|
||||
'local URL',
|
||||
'test',
|
||||
'http://localhost:4001/timer?param1=value1¶m2=value2',
|
||||
{
|
||||
alias: 'test',
|
||||
target: 'timer',
|
||||
search: 'param1=value1¶m2=value2',
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
[
|
||||
'IP-based URL',
|
||||
'test',
|
||||
'http://192.168.0.1:4001/timer?param1=value1¶m2=value2',
|
||||
{
|
||||
alias: 'test',
|
||||
target: 'timer',
|
||||
search: 'param1=value1¶m2=value2',
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
])('should generate URL preset options for %s', (_description, alias, url, expected) => {
|
||||
expect(generateUrlPresetOptions(alias, url)).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
it('throws on invalid URL', () => {
|
||||
expect(() => generateUrlPresetOptions('test', 'invalid-url')).toThrow();
|
||||
});
|
||||
|
||||
it('throws on on invalid route', () => {
|
||||
expect(() => generateUrlPresetOptions('test', 'www.getontime.no/somethingelse/')).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Path, resolvePath } from 'react-router-dom';
|
||||
import { URLPreset } from 'ontime-types';
|
||||
import { OntimeView, URLPreset } from 'ontime-types';
|
||||
import { checkRegex } from 'ontime-utils';
|
||||
|
||||
/**
|
||||
* Validates a preset against defined parameters
|
||||
@@ -49,7 +50,7 @@ export function getRouteFromPreset(location: Path, urlPresets: URLPreset[]): str
|
||||
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.pathAndParams, foundPreset.alias, locked, token);
|
||||
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
|
||||
@@ -63,7 +64,7 @@ export function getRouteFromPreset(location: Path, urlPresets: URLPreset[]): str
|
||||
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.pathAndParams, preset.alias, locked, token);
|
||||
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
|
||||
@@ -77,8 +78,14 @@ export function getRouteFromPreset(location: Path, urlPresets: URLPreset[]): str
|
||||
/**
|
||||
* Handles generating a path and search parameters from a preset
|
||||
*/
|
||||
export function generatePathFromPreset(pathAndParams: string, alias: string, locked: string | null, token: string | null ): string {
|
||||
const path = resolvePath(pathAndParams);
|
||||
export function generatePathFromPreset(
|
||||
target: Omit<OntimeView, 'editor'>,
|
||||
search: string,
|
||||
alias: string,
|
||||
locked: string | null,
|
||||
token: string | null,
|
||||
): string {
|
||||
const path = resolvePath(`${target}?${search}`);
|
||||
const searchParams = new URLSearchParams(path.search);
|
||||
|
||||
// save the alias so we have a reference to it being a preset and can update if necessary
|
||||
@@ -106,16 +113,16 @@ export function generatePathFromPreset(pathAndParams: string, alias: string, loc
|
||||
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
|
||||
if (currentUrl.pathname !== newUrl.pathname) {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
|
||||
// check search params
|
||||
// if the params match, we dont need further checks
|
||||
if (currentUrl.searchParams.toString() === newUrl.searchParams.toString()) {
|
||||
return true
|
||||
return true;
|
||||
}
|
||||
|
||||
// if there is no match, we check the edge cases for the url sharing feature
|
||||
@@ -124,3 +131,38 @@ export function arePathsEquivalent(currentPath: string, newPath: string): boolea
|
||||
|
||||
return currentUrl.searchParams.toString() === newUrl.searchParams.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a URL preset from a user given alias and URL.
|
||||
*/
|
||||
export function generateUrlPresetOptions(alias: string, userUrl: string): URLPreset {
|
||||
let sanitisedUrl = userUrl.toLowerCase();
|
||||
// we need to ensure the URL has a protocol, but it doesnt matter which
|
||||
if (!checkRegex.startsWithHttp(sanitisedUrl)) {
|
||||
sanitisedUrl = `http://${sanitisedUrl}`;
|
||||
}
|
||||
|
||||
const url = new URL(sanitisedUrl);
|
||||
const target = extractLastSegment(url.pathname);
|
||||
|
||||
if (target === 'editor' || !Object.values(OntimeView).includes(target as OntimeView)) {
|
||||
throw new Error(`Invalid target view: ${target}`);
|
||||
}
|
||||
|
||||
return {
|
||||
alias,
|
||||
target,
|
||||
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] || '';
|
||||
}
|
||||
|
||||
+15
-1
@@ -21,7 +21,15 @@ declare global {
|
||||
}
|
||||
|
||||
/**
|
||||
* We pass a custom property to the table meta to allow field update
|
||||
* Declare custom data we pass to the table
|
||||
* - `handleUpdate` callback to update the entry when the user edits a cell
|
||||
* - `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
|
||||
*
|
||||
* And metadata specific for each column
|
||||
* - `canWrite` whether the user can write to this column
|
||||
* - `colour` background colour associated with a custom field
|
||||
*/
|
||||
declare module '@tanstack/react-table' {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
@@ -33,6 +41,12 @@ declare module '@tanstack/react-table' {
|
||||
hideTableSeconds: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
interface ColumnMeta<TData extends RowData, TValue> {
|
||||
canWrite: boolean;
|
||||
colour?: string;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -106,6 +106,7 @@ $inner-padding: 1rem;
|
||||
th,
|
||||
td {
|
||||
padding: 0.5rem;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
tr:nth-child(even) {
|
||||
|
||||
@@ -28,7 +28,7 @@ export default function AboutPanel() {
|
||||
<ExternalLink href={buyMeACoffeeUrl}>Buy Me a Coffee</ExternalLink>
|
||||
</Panel.Paragraph>
|
||||
<Panel.Paragraph>
|
||||
And trying out our cloud service
|
||||
If you are looking for an online version of Ontime, consider trying out our cloud service
|
||||
<ExternalLink href={websiteUrl}>www.getontime.no</ExternalLink>
|
||||
</Panel.Paragraph>
|
||||
</Panel.Card>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
|
||||
import useAppVersion from '../../../../common/hooks-query/useAppVersion';
|
||||
import { appVersion } from '../../../../externals';
|
||||
import { appVersion, isOntimeCloud } from '../../../../externals';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
export default function AppVersion() {
|
||||
@@ -17,13 +17,17 @@ export default function AppVersion() {
|
||||
|
||||
if (data.hasUpdates) {
|
||||
return (
|
||||
<>
|
||||
<Panel.Paragraph>{`You are currently using Ontime version ${appVersion}.`}</Panel.Paragraph>
|
||||
<Panel.Paragraph>
|
||||
{`A new version ${data.version} is available.`}
|
||||
<ExternalLink href={data.url}> Please visit the release page to download</ExternalLink>
|
||||
</Panel.Paragraph>
|
||||
</>
|
||||
<Panel.Paragraph>
|
||||
{`You are currently using Ontime version ${appVersion}.`}
|
||||
<br />
|
||||
<br />
|
||||
{`A new version ${data.version} is available.`} <br />
|
||||
{isOntimeCloud ? (
|
||||
'You can restart your stage to get the latest available version.'
|
||||
) : (
|
||||
<ExternalLink href={data.url}>Please visit the release page to download</ExternalLink>
|
||||
)}
|
||||
</Panel.Paragraph>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import InfoNif from '../network-panel/NetworkInterfaces';
|
||||
|
||||
import GenerateLinkFormExport from './GenerateLinkFormExport';
|
||||
import ReportSettings from './ReportSettings';
|
||||
import UrlPresetsForm from './UrlPresetsForm';
|
||||
import URLPresets from './URLPresets';
|
||||
|
||||
export default function FeaturePanel({ location }: PanelBaseProps) {
|
||||
const presetsRef = useScrollIntoView<HTMLDivElement>('presets', location);
|
||||
@@ -17,7 +17,7 @@ export default function FeaturePanel({ location }: PanelBaseProps) {
|
||||
<>
|
||||
<Panel.Header>Sharing and reporting</Panel.Header>
|
||||
<div ref={presetsRef}>
|
||||
<UrlPresetsForm />
|
||||
<URLPresets />
|
||||
</div>
|
||||
<div ref={linkRef}>
|
||||
<Panel.Section>
|
||||
|
||||
+1
-1
@@ -33,7 +33,7 @@ export default function GenerateLinkFormExport({ lockedPath }: GenerateLinkFormE
|
||||
{ value: '', label: 'Companion' },
|
||||
...urlPresetData.map((preset) => ({
|
||||
value: preset.alias,
|
||||
label: `Preset: ${preset.alias}`,
|
||||
label: `URL Preset: ${preset.alias}`,
|
||||
})),
|
||||
];
|
||||
}, [lockedPath, urlPresetData]);
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { useState } from 'react';
|
||||
import { IoAdd, IoOpenOutline, IoPencil, IoTrash } from 'react-icons/io5';
|
||||
import { URLPreset } from 'ontime-types';
|
||||
|
||||
import Button from '../../../../common/components/buttons/Button';
|
||||
import IconButton from '../../../../common/components/buttons/IconButton';
|
||||
import Info from '../../../../common/components/info/Info';
|
||||
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
|
||||
import Switch from '../../../../common/components/switch/Switch';
|
||||
import useUrlPresets, { useUpdateUrlPreset } from '../../../../common/hooks-query/useUrlPresets';
|
||||
import { handleLinks } from '../../../../common/utils/linkUtils';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import URLPresetForm from './composite/URLPresetForm';
|
||||
|
||||
type FormState = {
|
||||
isOpen: boolean;
|
||||
preset?: URLPreset;
|
||||
};
|
||||
|
||||
const urlPresetsDocs = 'https://docs.getontime.no/features/url-presets/';
|
||||
|
||||
export default function URLPresets() {
|
||||
const [formState, setFormState] = useState<FormState>({ isOpen: false, preset: undefined });
|
||||
const { data, status } = useUrlPresets();
|
||||
const { deletePreset, isMutating } = useUpdateUrlPreset();
|
||||
|
||||
const openNewForm = () => setFormState({ isOpen: true });
|
||||
const openEditForm = (preset: URLPreset) => setFormState({ isOpen: true, preset });
|
||||
const closeForm = () => setFormState({ isOpen: false, preset: undefined });
|
||||
|
||||
return (
|
||||
<Panel.Section>
|
||||
<Panel.Card>
|
||||
<Panel.SubHeader>
|
||||
URL presets
|
||||
<Button onClick={openNewForm}>
|
||||
New <IoAdd />
|
||||
</Button>
|
||||
</Panel.SubHeader>
|
||||
<Panel.Divider />
|
||||
<Panel.Section>
|
||||
<Info>
|
||||
URL presets are user pre-defined aliases to Ontime URLs.
|
||||
<br />
|
||||
This URL can contain full configuration including parameters, or simply route to a specific view.
|
||||
<br />
|
||||
<br />
|
||||
The easiest way to get started is to copy an URL from your browser and paste it into the form.
|
||||
<ExternalLink href={urlPresetsDocs}>See the docs</ExternalLink>
|
||||
</Info>
|
||||
</Panel.Section>
|
||||
<Panel.Section>
|
||||
<Panel.Loader isLoading={status === 'pending'} />
|
||||
{formState.isOpen && <URLPresetForm urlPreset={formState.preset} onClose={closeForm} />}
|
||||
<Panel.Table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Enabled</th>
|
||||
<th>Alias</th>
|
||||
<th>Target view</th>
|
||||
<th>URL parameters</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.length === 0 && <Panel.TableEmpty handleClick={openNewForm} />}
|
||||
{data.map((preset, index) => {
|
||||
return (
|
||||
<tr key={preset.alias}>
|
||||
<td>
|
||||
<Switch defaultChecked={preset.enabled} onCheckedChange={() => {}} />
|
||||
</td>
|
||||
<td>{preset.alias}</td>
|
||||
<td>{preset.target}</td>
|
||||
<td>{preset.search}</td>
|
||||
<Panel.InlineElements relation='inner' as='td'>
|
||||
<IconButton
|
||||
variant='ghosted-white'
|
||||
onClick={(event) => handleLinks(preset.alias, event)}
|
||||
disabled={isMutating}
|
||||
>
|
||||
<IoOpenOutline />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
onClick={() => openEditForm(preset)}
|
||||
variant='ghosted-white'
|
||||
aria-label='Edit entry'
|
||||
data-testid={`field__edit_${index}`}
|
||||
disabled={isMutating}
|
||||
>
|
||||
<IoPencil />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
onClick={() => deletePreset(preset.alias)}
|
||||
variant='ghosted-destructive'
|
||||
aria-label='Delete entry'
|
||||
data-testid={`field__delete_${index}`}
|
||||
disabled={isMutating}
|
||||
>
|
||||
<IoTrash />
|
||||
</IconButton>
|
||||
</Panel.InlineElements>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</Panel.Table>
|
||||
</Panel.Section>
|
||||
</Panel.Card>
|
||||
</Panel.Section>
|
||||
);
|
||||
}
|
||||
@@ -1,230 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useFieldArray, useForm } from 'react-hook-form';
|
||||
import { IoAdd, IoOpenOutline, IoTrash } from 'react-icons/io5';
|
||||
import { URLPreset } from 'ontime-types';
|
||||
|
||||
import { postUrlPresets } from '../../../../common/api/urlPresets';
|
||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||
import Button from '../../../../common/components/buttons/Button';
|
||||
import IconButton from '../../../../common/components/buttons/IconButton';
|
||||
import Info from '../../../../common/components/info/Info';
|
||||
import Input from '../../../../common/components/input/input/Input';
|
||||
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
|
||||
import Switch from '../../../../common/components/switch/Switch';
|
||||
import Tooltip from '../../../../common/components/tooltip/Tooltip';
|
||||
import useUrlPresets from '../../../../common/hooks-query/useUrlPresets';
|
||||
import { preventEscape } from '../../../../common/utils/keyEvent';
|
||||
import { handleLinks } from '../../../../common/utils/linkUtils';
|
||||
import { validateUrlPresetPath } from '../../../../common/utils/urlPresets';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import style from './FeaturePanel.module.scss';
|
||||
|
||||
const urlPresetsDocs = 'https://docs.getontime.no/features/url-presets/';
|
||||
|
||||
type FormData = {
|
||||
data: URLPreset[];
|
||||
};
|
||||
|
||||
export default function UrlPresetsForm() {
|
||||
'use no memo'; // RHF and react-compiler don't seem to get along
|
||||
const { data, status, refetch } = useUrlPresets();
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
register,
|
||||
reset,
|
||||
setError,
|
||||
watch,
|
||||
setValue,
|
||||
formState: { isSubmitting, isDirty, isValid, errors },
|
||||
} = useForm<FormData>({
|
||||
mode: 'onChange',
|
||||
defaultValues: { data },
|
||||
values: { data },
|
||||
resetOptions: {
|
||||
keepDirtyValues: true,
|
||||
},
|
||||
});
|
||||
const { fields, prepend, remove } = useFieldArray({
|
||||
name: 'data',
|
||||
control,
|
||||
});
|
||||
|
||||
// reset form if we get new data from backend
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
reset({ data });
|
||||
}
|
||||
}, [data, reset]);
|
||||
|
||||
const onSubmit = async (formData: FormData) => {
|
||||
for (let i = 0; i < formData.data.length; i++) {
|
||||
const preset = formData.data[i];
|
||||
const { isValid, message } = validateUrlPresetPath(preset.pathAndParams);
|
||||
if (!isValid) {
|
||||
setError(`data.${i}.pathAndParams`, { message });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await postUrlPresets(formData.data);
|
||||
} catch (error) {
|
||||
const message = maybeAxiosError(error);
|
||||
setError('root', { message });
|
||||
} finally {
|
||||
await refetch();
|
||||
}
|
||||
};
|
||||
|
||||
const onReset = () => {
|
||||
reset({ data });
|
||||
};
|
||||
|
||||
const addNew = () => {
|
||||
prepend({
|
||||
enabled: true,
|
||||
alias: '',
|
||||
pathAndParams: '',
|
||||
});
|
||||
};
|
||||
|
||||
const isLoading = status === 'pending';
|
||||
const canSubmit = !isSubmitting && isDirty && isValid;
|
||||
|
||||
return (
|
||||
<Panel.Section
|
||||
as='form'
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
onKeyDown={(event) => preventEscape(event, onReset)}
|
||||
data-testid='url-preset-form'
|
||||
>
|
||||
<Panel.Card>
|
||||
<Panel.SubHeader>
|
||||
URL presets
|
||||
<Panel.InlineElements>
|
||||
<Button variant='ghosted' onClick={onReset} disabled={!canSubmit}>
|
||||
Revert to saved
|
||||
</Button>
|
||||
<Button variant='primary' type='submit' disabled={!canSubmit} loading={isSubmitting}>
|
||||
Save
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
</Panel.SubHeader>
|
||||
<Panel.Divider />
|
||||
<Info>
|
||||
URL presets are user defined aliases to Ontime URLs
|
||||
<br />
|
||||
<br />
|
||||
<b>Preset Name</b> <br />
|
||||
The alias for the URL. This will be the URL you will be calling. eg: <br />
|
||||
<Panel.BlockQuote>
|
||||
Preset name <Panel.Highlight>cam3</Panel.Highlight> called as{' '}
|
||||
<Panel.Highlight>http://localhost:4001/cam3</Panel.Highlight>
|
||||
</Panel.BlockQuote>
|
||||
<br />
|
||||
<b>URL Segment</b> <br />
|
||||
The corresponding alias path and configuration parameters. eg: <br />
|
||||
<Panel.BlockQuote>
|
||||
URL segment <Panel.Highlight>backstage?hidePast=true&stopCycle=true</Panel.Highlight> corresponds to
|
||||
complete URL
|
||||
<Panel.Highlight>http://localhost:4001/backstage?hidePast=true&stopCycle=true</Panel.Highlight>
|
||||
</Panel.BlockQuote>
|
||||
<br />
|
||||
You will need to save the changes before the presets are functional.
|
||||
<br />
|
||||
<ExternalLink href={urlPresetsDocs}>See the docs</ExternalLink>
|
||||
</Info>
|
||||
<Panel.Section>
|
||||
<Panel.Loader isLoading={isLoading} />
|
||||
<Panel.Title>
|
||||
Manage presets
|
||||
<Button onClick={addNew}>
|
||||
New <IoAdd />
|
||||
</Button>
|
||||
</Panel.Title>
|
||||
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}
|
||||
{errors?.data && <Panel.Error>{errors.data.message}</Panel.Error>}
|
||||
|
||||
<Panel.Table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={style.fit}>Active</th>
|
||||
<th className={style.aliasConstrain}>Preset name</th>
|
||||
<th className={style.fullWidth}>URL segment</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{fields.length === 0 && <Panel.TableEmpty handleClick={addNew} />}
|
||||
{fields.map((preset, index) => {
|
||||
const maybeAliasError = errors.data?.[index]?.alias?.message;
|
||||
const maybeUrlError = errors.data?.[index]?.pathAndParams?.message;
|
||||
// only saved and enabled URLs can be tested
|
||||
const canTest =
|
||||
preset.alias && preset.enabled && preset.pathAndParams && !maybeAliasError && !maybeUrlError;
|
||||
|
||||
return (
|
||||
<tr key={preset.id}>
|
||||
<td className={style.fit}>
|
||||
<Switch
|
||||
size='large'
|
||||
checked={watch(`data.${index}.enabled`)}
|
||||
onCheckedChange={(value: boolean) =>
|
||||
setValue(`data.${index}.enabled`, value, { shouldDirty: true })
|
||||
}
|
||||
data-testid={`field__enable_${index}`}
|
||||
/>
|
||||
</td>
|
||||
<td className={style.aliasConstrain}>
|
||||
<Input
|
||||
{...register(`data.${index}.alias`, {
|
||||
required: { value: true, message: 'Required field' },
|
||||
})}
|
||||
fluid
|
||||
placeholder='URL Preset'
|
||||
data-testid={`field__alias_${index}`}
|
||||
/>
|
||||
<Panel.Error>{maybeAliasError}</Panel.Error>
|
||||
</td>
|
||||
<td className={style.fullWidth}>
|
||||
<Input
|
||||
{...register(`data.${index}.pathAndParams`, {
|
||||
required: { value: true, message: 'Required field' },
|
||||
})}
|
||||
fluid
|
||||
placeholder='URL (portion after ontime Port)'
|
||||
data-testid={`field__url_${index}`}
|
||||
/>
|
||||
<Panel.Error>{maybeUrlError}</Panel.Error>
|
||||
</td>
|
||||
<Panel.InlineElements relation='inner' as='td'>
|
||||
<Tooltip
|
||||
text='Test preset'
|
||||
render={<IconButton variant='ghosted-white' />}
|
||||
data-testid={`field__test_${index}`}
|
||||
onClick={(event) => handleLinks(preset.alias, event)}
|
||||
disabled={!canTest}
|
||||
>
|
||||
<IoOpenOutline />
|
||||
</Tooltip>
|
||||
<IconButton
|
||||
onClick={() => remove(index)}
|
||||
variant='ghosted-destructive'
|
||||
aria-label='Delete entry'
|
||||
data-testid={`field__delete_${index}`}
|
||||
>
|
||||
<IoTrash />
|
||||
</IconButton>
|
||||
</Panel.InlineElements>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</Panel.Table>
|
||||
</Panel.Section>
|
||||
</Panel.Card>
|
||||
</Panel.Section>
|
||||
);
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
.expand {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { OntimeView, URLPreset } from 'ontime-types';
|
||||
|
||||
import { maybeAxiosError, unwrapError } from '../../../../../common/api/utils';
|
||||
import Button from '../../../../../common/components/buttons/Button';
|
||||
import Input from '../../../../../common/components/input/input/Input';
|
||||
import Select, { SelectOption } from '../../../../../common/components/select/Select';
|
||||
import { useUpdateUrlPreset } from '../../../../../common/hooks-query/useUrlPresets';
|
||||
import { preventEscape } from '../../../../../common/utils/keyEvent';
|
||||
import { generateUrlPresetOptions } from '../../../../../common/utils/urlPresets';
|
||||
import * as Panel from '../../../panel-utils/PanelUtils';
|
||||
|
||||
import style from './URLPresetForm.module.scss';
|
||||
|
||||
const targetOptions: SelectOption[] = [
|
||||
{ value: OntimeView.Cuesheet, label: 'Cuesheet' },
|
||||
{ value: OntimeView.Operator, label: 'Operator' },
|
||||
{ value: OntimeView.Timer, label: 'Timer' },
|
||||
{ value: OntimeView.Backstage, label: 'Backstage' },
|
||||
{ value: OntimeView.Timeline, label: 'Timeline' },
|
||||
{ value: OntimeView.StudioClock, label: 'Studio Clock' },
|
||||
{ value: OntimeView.Countdown, label: 'Countdown' },
|
||||
{ value: OntimeView.ProjectInfo, label: 'Project Info' },
|
||||
];
|
||||
|
||||
const defaultValues: URLPreset = {
|
||||
alias: '',
|
||||
target: OntimeView.Timer,
|
||||
search: '',
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
interface URLPresetFormProps {
|
||||
urlPreset?: URLPreset;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function URLPresetForm({ urlPreset, onClose }: URLPresetFormProps) {
|
||||
const { addPreset, updatePreset, isMutating } = useUpdateUrlPreset();
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
register,
|
||||
setFocus,
|
||||
setError,
|
||||
clearErrors,
|
||||
setValue,
|
||||
getValues,
|
||||
watch,
|
||||
formState: { errors, isSubmitting, isValid, isDirty },
|
||||
} = useForm<URLPreset>({
|
||||
defaultValues: urlPreset ?? defaultValues,
|
||||
resetOptions: {
|
||||
keepDirtyValues: true,
|
||||
},
|
||||
});
|
||||
const urlRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const setupSubmit = async (data: URLPreset) => {
|
||||
try {
|
||||
if (urlPreset) {
|
||||
await updatePreset(urlPreset.alias, data);
|
||||
} else {
|
||||
await addPreset(data);
|
||||
}
|
||||
onClose();
|
||||
} catch (error) {
|
||||
setError('root', { message: maybeAxiosError(error) });
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setFocus('alias');
|
||||
}, [setFocus]);
|
||||
|
||||
const generateOptions = () => {
|
||||
clearErrors();
|
||||
|
||||
try {
|
||||
const preset = generateUrlPresetOptions(getValues('alias'), urlRef.current?.value.trim() ?? '');
|
||||
setValue('target', preset.target, { shouldDirty: true, shouldValidate: true });
|
||||
setValue('search', preset.search, { shouldDirty: true, shouldValidate: true });
|
||||
} catch (error) {
|
||||
setError('root', { message: unwrapError(error) });
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const validateParams = (value: string) => {
|
||||
try {
|
||||
new URLSearchParams(value);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return unwrapError(error) || 'Invalid URL parameters';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Panel.Indent
|
||||
as='form'
|
||||
onSubmit={handleSubmit(setupSubmit)}
|
||||
onKeyDown={(event) => preventEscape(event, onClose)}
|
||||
className={style.column}
|
||||
>
|
||||
<input hidden name='enabled' value='true' />
|
||||
|
||||
<div>1. Enter URL and let Ontime generate the preset options</div>
|
||||
<Panel.InlineElements>
|
||||
<div>
|
||||
<Panel.Description>Alias</Panel.Description>
|
||||
<Input {...register('alias', { required: 'Alias is required' })} />
|
||||
</div>
|
||||
<div className={style.expand}>
|
||||
<Panel.Description>Generate options (paste URL to generate options)</Panel.Description>
|
||||
<Panel.InlineElements>
|
||||
<Input placeholder='Paste URL' fluid ref={urlRef} required />
|
||||
<Button onClick={generateOptions}>Generate</Button>
|
||||
</Panel.InlineElements>
|
||||
</div>
|
||||
</Panel.InlineElements>
|
||||
<div> - or -</div>
|
||||
<div>2. Choose a view and its parameters</div>
|
||||
<div>
|
||||
<Panel.Description>Target</Panel.Description>
|
||||
<Select
|
||||
options={targetOptions}
|
||||
{...register('target', { required: 'Target is required' })}
|
||||
value={watch('target') as OntimeView}
|
||||
onValueChange={(value) => setValue('target', value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Panel.Description>Parameters</Panel.Description>
|
||||
<Input
|
||||
fluid
|
||||
{...register('search', {
|
||||
validate: validateParams,
|
||||
})}
|
||||
/>
|
||||
<Panel.Error>{errors.search?.message}</Panel.Error>
|
||||
</div>
|
||||
<div>
|
||||
<Panel.Error>{errors.root?.message}</Panel.Error>
|
||||
<Panel.InlineElements align='end'>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
<Button variant='primary' type='submit' disabled={!isValid || !isDirty} loading={isSubmitting || isMutating}>
|
||||
Save
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
</div>
|
||||
</Panel.Indent>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { CustomField, CustomFieldKey } from 'ontime-types';
|
||||
import { deleteCustomField, editCustomField, postCustomField } from '../../../../common/api/customFields';
|
||||
import Button from '../../../../common/components/buttons/Button';
|
||||
import Info from '../../../../common/components/info/Info';
|
||||
import AppLink from '../../../../common/components/link/app-link/AppLink';
|
||||
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
|
||||
import useCustomFields from '../../../../common/hooks-query/useCustomFields';
|
||||
import { customFieldsDocsUrl } from '../../../../externals';
|
||||
@@ -60,7 +61,8 @@ export default function CustomFieldSettings() {
|
||||
Custom fields allow for additional information to be added to an event.
|
||||
<br />
|
||||
<br />
|
||||
This data can be used in the Automation feature by using the generated key.
|
||||
To use custom fields as a data source in an{' '}
|
||||
<AppLink search='settings=automation__automations'>Automation</AppLink>, please note the generated key.
|
||||
<ExternalLink href={customFieldsDocsUrl}>See the docs</ExternalLink>
|
||||
</Info>
|
||||
</Panel.Section>
|
||||
@@ -72,7 +74,7 @@ export default function CustomFieldSettings() {
|
||||
<th>Colour</th>
|
||||
<th>Type</th>
|
||||
<th>Name</th>
|
||||
<th>Key (used in Integrations)</th>
|
||||
<th>Key (used in Automations)</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { CustomField } from 'ontime-types';
|
||||
import { customFieldLabelToKey, isAlphanumericWithSpace } from 'ontime-utils';
|
||||
import { checkRegex, customFieldLabelToKey } from 'ontime-utils';
|
||||
|
||||
import { maybeAxiosError } from '../../../../../common/api/utils';
|
||||
import Button from '../../../../../common/components/buttons/Button';
|
||||
@@ -117,7 +117,7 @@ export default function CustomFieldForm({
|
||||
onChange: () => setValue('key', customFieldLabelToKey(getValues('label')) ?? 'N/A'),
|
||||
validate: (value) => {
|
||||
if (value.trim().length === 0) return 'Required field';
|
||||
if (!isAlphanumericWithSpace(value)) return 'Only alphanumeric characters and space are allowed';
|
||||
if (!checkRegex.isAlphanumericWithSpace(value)) return 'Only alphanumeric characters and space are allowed';
|
||||
if (!isEditMode) {
|
||||
if (isEditMode && Object.keys(data).includes(value)) return 'Custom fields must be unique';
|
||||
}
|
||||
|
||||
+4
-4
@@ -6,12 +6,12 @@ const googleSheetDocsUrl = 'https://docs.getontime.no/features/import-spreadshee
|
||||
export default function GSheetInfo() {
|
||||
return (
|
||||
<Info>
|
||||
Ontime allows you to synchronize your rundown with a Google Sheet.
|
||||
Ontime can import data from spreadsheets by: <br />
|
||||
- importing the spreadsheet file in Ontime <br />
|
||||
- synchronising your project with a Google Sheet
|
||||
<br />
|
||||
<br />
|
||||
To enable this feature, you will need to generate tokens in your Google account and provide them to Ontime.
|
||||
<br />
|
||||
Once set up, you will be able to synchronize data between Ontime and your Google Sheet. <br />
|
||||
To synchronise with a Google Sheet, you will need to allow Ontime to authenticate with your Google account.
|
||||
<ExternalLink href={googleSheetDocsUrl}>See the docs</ExternalLink>
|
||||
</Info>
|
||||
);
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useFieldArray, useForm } from 'react-hook-form';
|
||||
import { IoAdd, IoTrash } from 'react-icons/io5';
|
||||
import { ImportMap, isAlphanumericWithSpace } from 'ontime-utils';
|
||||
import { checkRegex, ImportMap } from 'ontime-utils';
|
||||
|
||||
import Button from '../../../../../../common/components/buttons/Button';
|
||||
import IconButton from '../../../../../../common/components/buttons/IconButton';
|
||||
@@ -200,7 +200,7 @@ export default function ImportMapForm({
|
||||
placeholder='Name of the field as shown in Ontime'
|
||||
{...register(`custom.${index}.ontimeName`, {
|
||||
validate: (value) => {
|
||||
if (!isAlphanumericWithSpace(value))
|
||||
if (!checkRegex.isAlphanumericWithSpace(value))
|
||||
return 'Only alphanumeric characters and space are allowed';
|
||||
return true;
|
||||
},
|
||||
|
||||
@@ -4,12 +4,12 @@ import { IoAdd, IoDownloadOutline, IoTrash } from 'react-icons/io5';
|
||||
import { type ProjectData } from 'ontime-types';
|
||||
|
||||
import { projectLogoPath } from '../../../../common/api/constants';
|
||||
import { postProjectData, uploadProjectLogo } from '../../../../common/api/project';
|
||||
import { uploadProjectLogo } from '../../../../common/api/project';
|
||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||
import Button from '../../../../common/components/buttons/Button';
|
||||
import Input from '../../../../common/components/input/input/Input';
|
||||
import Textarea from '../../../../common/components/input/textarea/Textarea';
|
||||
import useProjectData from '../../../../common/hooks-query/useProjectData';
|
||||
import useProjectData, { useUpdateProjectData } from '../../../../common/hooks-query/useProjectData';
|
||||
import { preventEscape } from '../../../../common/utils/keyEvent';
|
||||
import { validateLogo } from '../../../../common/utils/uploadUtils';
|
||||
import { documentationUrl } from '../../../../externals';
|
||||
@@ -19,7 +19,8 @@ import style from './SettingsPanel.module.scss';
|
||||
|
||||
export default function ProjectData() {
|
||||
'use no memo'; // RHF and react-compiler don't seem to get along
|
||||
const { data, status, refetch } = useProjectData();
|
||||
const { data, status } = useProjectData();
|
||||
const { updateProjectData } = useUpdateProjectData();
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
@@ -93,16 +94,14 @@ export default function ProjectData() {
|
||||
|
||||
const onSubmit = async (formData: ProjectData) => {
|
||||
try {
|
||||
await postProjectData(formData);
|
||||
await updateProjectData(formData);
|
||||
} catch (error) {
|
||||
const message = maybeAxiosError(error);
|
||||
setError('root', { message });
|
||||
} finally {
|
||||
await refetch();
|
||||
}
|
||||
};
|
||||
|
||||
// populate with new data if we get an update
|
||||
// reset data to the initial state
|
||||
const onReset = () => {
|
||||
reset(data);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
.column {
|
||||
height: 100%;
|
||||
justify-content: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: end;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.row {
|
||||
|
||||
@@ -194,7 +194,7 @@ export function ProgressOverview() {
|
||||
const current = selectedEventIndex !== null ? selectedEventIndex + 1 : enDash;
|
||||
const progressText = numEvents ? `${current} of ${numEvents || enDash}` : enDash;
|
||||
|
||||
return <TimeColumn label='Progress' value={progressText} />;
|
||||
return <TimeColumn label='Progress' value={progressText} muted={selectedEventIndex === null} />;
|
||||
}
|
||||
|
||||
export function OffsetOverview() {
|
||||
|
||||
@@ -13,11 +13,17 @@
|
||||
&::after {
|
||||
content: '\200b';
|
||||
}
|
||||
|
||||
&.muted {
|
||||
color: $muted-gray;
|
||||
}
|
||||
}
|
||||
|
||||
.column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
justify-content: end;
|
||||
|
||||
.label {
|
||||
line-height: 0.9em;
|
||||
|
||||
@@ -56,6 +56,7 @@
|
||||
|
||||
.index {
|
||||
grid-row: 2;
|
||||
align-content: center;
|
||||
}
|
||||
|
||||
.entry {
|
||||
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
reorderArray,
|
||||
} from 'ontime-utils';
|
||||
|
||||
import { type EventOptions, useEntryActions } from '../../common/hooks/useEntryAction';
|
||||
import { useEntryActions } from '../../common/hooks/useEntryAction';
|
||||
import useFollowComponent from '../../common/hooks/useFollowComponent';
|
||||
import { useRundownEditor } from '../../common/hooks/useSocket';
|
||||
import { useEntryCopy } from '../../common/stores/entryCopyStore';
|
||||
@@ -114,20 +114,16 @@ export default function Rundown({ data }: RundownProps) {
|
||||
[addEntry, order, entries],
|
||||
);
|
||||
|
||||
/**
|
||||
* Add a new item referring to an existing one
|
||||
*/
|
||||
const insertAtId = useCallback(
|
||||
(patch: Partial<OntimeEntry> & { type: SupportedEntry }, id: MaybeString, above = false) => {
|
||||
const options: EventOptions =
|
||||
id === null
|
||||
? {}
|
||||
: {
|
||||
after: above ? undefined : id,
|
||||
before: above ? id : undefined,
|
||||
};
|
||||
|
||||
if (!above && id) {
|
||||
options.lastEventId = id;
|
||||
}
|
||||
addEntry(patch, options);
|
||||
addEntry(patch, {
|
||||
after: id && !above ? id : undefined,
|
||||
before: id && above ? id : undefined,
|
||||
lastEventId: !above && id ? id : undefined,
|
||||
});
|
||||
},
|
||||
[addEntry],
|
||||
);
|
||||
@@ -239,52 +235,72 @@ export default function Rundown({ data }: RundownProps) {
|
||||
|
||||
// shortcuts
|
||||
useHotkeys([
|
||||
['alt + ArrowDown', () => selectEntry(cursor, 'down'), { preventDefault: true }],
|
||||
['alt + ArrowUp', () => selectEntry(cursor, 'up'), { preventDefault: true }],
|
||||
['alt + ArrowDown', () => selectEntry(cursor, 'down'), { preventDefault: true, usePhysicalKeys: true }],
|
||||
['alt + ArrowUp', () => selectEntry(cursor, 'up'), { preventDefault: true, usePhysicalKeys: true }],
|
||||
|
||||
['alt + shift + ArrowDown', () => selectBlock(cursor, 'down'), { preventDefault: true }],
|
||||
['alt + shift + ArrowUp', () => selectBlock(cursor, 'up'), { preventDefault: true }],
|
||||
['alt + shift + ArrowDown', () => selectBlock(cursor, 'down'), { preventDefault: true, usePhysicalKeys: true }],
|
||||
['alt + shift + ArrowUp', () => selectBlock(cursor, 'up'), { preventDefault: true, usePhysicalKeys: true }],
|
||||
|
||||
['alt + mod + ArrowDown', () => moveEntry(cursor, 'down'), { preventDefault: true }],
|
||||
['alt + mod + ArrowUp', () => moveEntry(cursor, 'up'), { preventDefault: true }],
|
||||
['alt + mod + ArrowDown', () => moveEntry(cursor, 'down'), { preventDefault: true, usePhysicalKeys: true }],
|
||||
['alt + mod + ArrowUp', () => moveEntry(cursor, 'up'), { preventDefault: true, usePhysicalKeys: true }],
|
||||
|
||||
['Escape', () => clearSelectedEvents(), { preventDefault: true }],
|
||||
['Escape', () => clearSelectedEvents(), { preventDefault: true, usePhysicalKeys: true }],
|
||||
|
||||
['mod + Backspace', () => deleteAtCursor(cursor), { preventDefault: true }],
|
||||
['mod + Backspace', () => deleteAtCursor(cursor), { preventDefault: true, usePhysicalKeys: true }],
|
||||
|
||||
[
|
||||
'alt + E',
|
||||
() => insertAtId({ type: SupportedEntry.Event }, cursor),
|
||||
{ preventDefault: true, usePhysicalKeys: true },
|
||||
],
|
||||
['alt + shift + E', () => insertAtId({ type: SupportedEntry.Event }, cursor, true), { preventDefault: true }],
|
||||
[
|
||||
'alt + shift + E',
|
||||
() => insertAtId({ type: SupportedEntry.Event }, cursor, true),
|
||||
{ preventDefault: true, usePhysicalKeys: true },
|
||||
],
|
||||
|
||||
[
|
||||
'alt + G',
|
||||
() => insertAtId({ type: SupportedEntry.Block }, cursor),
|
||||
{ preventDefault: true, usePhysicalKeys: true },
|
||||
],
|
||||
['alt + shift + G', () => insertAtId({ type: SupportedEntry.Block }, cursor, true), { preventDefault: true }],
|
||||
[
|
||||
'alt + shift + G',
|
||||
() => insertAtId({ type: SupportedEntry.Block }, cursor, true),
|
||||
{ preventDefault: true, usePhysicalKeys: true },
|
||||
],
|
||||
|
||||
[
|
||||
'alt + D',
|
||||
() => insertAtId({ type: SupportedEntry.Delay }, cursor),
|
||||
{ preventDefault: true, usePhysicalKeys: true },
|
||||
],
|
||||
['alt + shift + D', () => insertAtId({ type: SupportedEntry.Delay }, cursor, true), { preventDefault: true }],
|
||||
[
|
||||
'alt + shift + D',
|
||||
() => insertAtId({ type: SupportedEntry.Delay }, cursor, true),
|
||||
{ preventDefault: true, usePhysicalKeys: true },
|
||||
],
|
||||
|
||||
[
|
||||
'alt + M',
|
||||
() => insertAtId({ type: SupportedEntry.Milestone }, cursor),
|
||||
{ preventDefault: true, usePhysicalKeys: true },
|
||||
],
|
||||
['alt + shift + M', () => insertAtId({ type: SupportedEntry.Milestone }, cursor, true), { preventDefault: true }],
|
||||
[
|
||||
'alt + shift + M',
|
||||
() => insertAtId({ type: SupportedEntry.Milestone }, cursor, true),
|
||||
{ preventDefault: true, usePhysicalKeys: true },
|
||||
],
|
||||
|
||||
['mod + C', () => setEntryCopyId(cursor)],
|
||||
['mod + V', () => insertCopyAtId(cursor, entryCopyId)],
|
||||
['mod + shift + V', () => insertCopyAtId(cursor, entryCopyId, true), { preventDefault: true }],
|
||||
[
|
||||
'mod + shift + V',
|
||||
() => insertCopyAtId(cursor, entryCopyId, true),
|
||||
{ preventDefault: true, usePhysicalKeys: true },
|
||||
],
|
||||
|
||||
['alt + backspace', () => deleteAtCursor(cursor), { preventDefault: true }],
|
||||
['alt + backspace', () => deleteAtCursor(cursor), { preventDefault: true, usePhysicalKeys: true }],
|
||||
]);
|
||||
|
||||
// we copy the state from the store here
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
color: $section-white; // to override inline
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: 2rem 8rem 1fr auto;
|
||||
grid-template-columns: 2rem 1fr 3fr;
|
||||
align-items: center;
|
||||
height: $secondary-block-height;
|
||||
gap: 0.5rem;
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { MouseEvent, useCallback, useRef } from 'react';
|
||||
import { IoClose, IoReorderTwo } from 'react-icons/io5';
|
||||
import { IoReorderTwo, IoTrash } from 'react-icons/io5';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { EntryId } from 'ontime-types';
|
||||
|
||||
import Button from '../../../common/components/buttons/Button';
|
||||
import Input from '../../../common/components/input/input/Input';
|
||||
import useReactiveTextInput from '../../../common/components/input/text-input/useReactiveTextInput';
|
||||
import { useContextMenu } from '../../../common/hooks/useContextMenu';
|
||||
import { useEntryActions } from '../../../common/hooks/useEntryAction';
|
||||
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
|
||||
import { useEventSelection } from '../useEventSelection';
|
||||
@@ -26,6 +26,15 @@ export default function RundownMilestone({ colour, cue, entryId, hasCursor, titl
|
||||
const { updateEntry, deleteEntry } = useEntryActions();
|
||||
const { selectedEvents, setSelectedBlock } = useEventSelection();
|
||||
|
||||
const [onContextMenu] = useContextMenu<HTMLDivElement>([
|
||||
{
|
||||
type: 'item',
|
||||
label: 'Delete',
|
||||
icon: IoTrash,
|
||||
onClick: () => deleteEntry([entryId]),
|
||||
},
|
||||
]);
|
||||
|
||||
const {
|
||||
attributes: dragAttributes,
|
||||
listeners: dragListeners,
|
||||
@@ -59,11 +68,6 @@ export default function RundownMilestone({ colour, cue, entryId, hasCursor, titl
|
||||
updateEntry({ id: entryId, [field]: value });
|
||||
};
|
||||
|
||||
const handleDelete = (event: MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
deleteEntry([entryId]);
|
||||
};
|
||||
|
||||
const dragStyle = {
|
||||
zIndex: isDragging ? 2 : 'inherit',
|
||||
transform: CSS.Translate.toString(transform),
|
||||
@@ -77,6 +81,7 @@ export default function RundownMilestone({ colour, cue, entryId, hasCursor, titl
|
||||
className={cx([style.milestone, hasCursor ? style.hasCursor : null])}
|
||||
ref={setNodeRef}
|
||||
onClick={handleFocusClick}
|
||||
onContextMenu={onContextMenu}
|
||||
style={dragStyle}
|
||||
data-testid='rundown-milestone'
|
||||
>
|
||||
@@ -87,9 +92,6 @@ export default function RundownMilestone({ colour, cue, entryId, hasCursor, titl
|
||||
</div>
|
||||
<MilestoneTextInput field='cue' initialValue={cue} placeholder='Cue' submitHandler={handleUpdate} />
|
||||
<MilestoneTextInput field='title' initialValue={title} placeholder='Title' submitHandler={handleUpdate} />
|
||||
<Button variant='ghosted-destructive' onClick={handleDelete}>
|
||||
<IoClose /> Cancel
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -149,10 +149,27 @@
|
||||
.info {
|
||||
grid-area: info;
|
||||
display: flex;
|
||||
gap: max(1vw, 16px);
|
||||
align-self: flex-end;
|
||||
flex-direction: column;
|
||||
gap: $view-element-gap;
|
||||
align-self: end;
|
||||
overflow: hidden;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.info__column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.info-card {
|
||||
display: flex;
|
||||
gap: $view-element-gap;
|
||||
|
||||
&__label {
|
||||
font-size: $timer-label-size;
|
||||
color: var(--label-color-override, $viewer-label-color);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
&__message {
|
||||
font-size: $base-font-size;
|
||||
@@ -162,11 +179,16 @@
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.qr {
|
||||
&__qr {
|
||||
padding: 0.5rem;
|
||||
background-color: $ui-white;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
&__img {
|
||||
padding: 0.5rem;
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ export default function Backstage({
|
||||
settings,
|
||||
}: BackstageProps) {
|
||||
const { getLocalizedString } = useTranslation();
|
||||
const { secondarySource } = useBackstageOptions();
|
||||
const { secondarySource, extraInfo } = useBackstageOptions();
|
||||
const [blinkClass, setBlinkClass] = useState(false);
|
||||
const { height: screenHeight } = useViewportSize();
|
||||
|
||||
@@ -104,8 +104,8 @@ export default function Backstage({
|
||||
// gather option data
|
||||
const defaultFormat = getDefaultFormat(settings?.timeFormat);
|
||||
const backstageOptions = useMemo(
|
||||
() => getBackstageOptions(defaultFormat, customFields),
|
||||
[defaultFormat, customFields],
|
||||
() => getBackstageOptions(defaultFormat, customFields, general),
|
||||
[defaultFormat, customFields, general],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -180,8 +180,44 @@ export default function Backstage({
|
||||
{showSchedule && <ScheduleExport selectedId={selectedId} />}
|
||||
|
||||
<div className={cx(['info', !showSchedule && 'info--stretch'])}>
|
||||
{general.url && <QRCode value={general.url} size={qrSize} level='L' className='qr' />}
|
||||
{general.info && <div className='info__message'>{general.info}</div>}
|
||||
{extraInfo && <ExtraInfo projectData={general} size={qrSize} source={extraInfo} />}
|
||||
<div className='info-card'>
|
||||
{general.url && <QRCode value={general.url} size={qrSize} level='L' className='info-card__qr' />}
|
||||
{general.info && <div className='info-card__message'>{general.info}</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ExtraInfoProps {
|
||||
projectData: ProjectData;
|
||||
size: number;
|
||||
source: string;
|
||||
}
|
||||
function ExtraInfo({ projectData, size, source }: ExtraInfoProps) {
|
||||
const info = projectData.custom.find((entry, index) => {
|
||||
const label = `${index}-${entry.title}`;
|
||||
return label === source;
|
||||
});
|
||||
|
||||
if (!info) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='info-card'>
|
||||
{info.url && (
|
||||
<img
|
||||
className='info-card__img'
|
||||
width={size}
|
||||
src={info.url}
|
||||
onError={(event) => (event.currentTarget.style.display = 'none')}
|
||||
/>
|
||||
)}
|
||||
<div className='info__column'>
|
||||
{info.title && <div className='info-card__label'>{info.title}</div>}
|
||||
{info.value && <div className='info-card__message'>{info.value}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { CustomFields, OntimeEvent } from 'ontime-types';
|
||||
import { CustomFields, OntimeEvent, ProjectData } from 'ontime-types';
|
||||
|
||||
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 { makeOptionsFromCustomFields } from '../../common/components/view-params-editor/viewParams.utils';
|
||||
import {
|
||||
makeOptionsFromCustomFields,
|
||||
makeProjectDataOptions,
|
||||
} from '../../common/components/view-params-editor/viewParams.utils';
|
||||
import { scheduleOptions } from '../common/schedule/schedule.options';
|
||||
|
||||
export const getBackstageOptions = (timeFormat: string, customFields: CustomFields): ViewOption[] => {
|
||||
export const getBackstageOptions = (
|
||||
timeFormat: string,
|
||||
customFields: CustomFields,
|
||||
projectData: ProjectData,
|
||||
): ViewOption[] => {
|
||||
const secondaryOptions = makeOptionsFromCustomFields(customFields, [{ value: 'note', label: 'Note' }]);
|
||||
const projectDataOptions = makeProjectDataOptions(projectData);
|
||||
|
||||
return [
|
||||
{ title: OptionTitle.ClockOptions, collapsible: true, options: [getTimeOption(timeFormat)] },
|
||||
@@ -28,11 +36,26 @@ export const getBackstageOptions = (timeFormat: string, customFields: CustomFiel
|
||||
],
|
||||
},
|
||||
scheduleOptions,
|
||||
{
|
||||
title: OptionTitle.ElementVisibility,
|
||||
collapsible: true,
|
||||
options: [
|
||||
{
|
||||
id: 'extra-info',
|
||||
title: 'Extra info',
|
||||
description: 'Select a project data source to show in the view',
|
||||
type: 'option',
|
||||
values: projectDataOptions,
|
||||
defaultValue: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
type BackstageOptions = {
|
||||
secondarySource: keyof OntimeEvent | null;
|
||||
extraInfo: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -43,6 +66,7 @@ function getOptionsFromParams(searchParams: URLSearchParams): BackstageOptions {
|
||||
// we manually make an object that matches the key above
|
||||
return {
|
||||
secondarySource: searchParams.get('secondary-src') as keyof OntimeEvent | null,
|
||||
extraInfo: searchParams.get('extra-info'),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,35 +1,24 @@
|
||||
import { useMemo } from 'react';
|
||||
import { IoApps } from 'react-icons/io5';
|
||||
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 EmptyPage from '../../common/components/state/EmptyPage';
|
||||
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
|
||||
import useCustomFields from '../../common/hooks-query/useCustomFields';
|
||||
import { useFlatRundown } from '../../common/hooks-query/useRundown';
|
||||
import CuesheetOverview from '../../features/overview/CuesheetOverview';
|
||||
|
||||
import CuesheetDnd from './cuesheet-dnd/CuesheetDnd';
|
||||
import CuesheetEditModal from './cuesheet-edit-modal/CuesheetEditModal';
|
||||
import CuesheetProgress from './cuesheet-progress/CuesheetProgress';
|
||||
import { makeCuesheetColumns } from './cuesheet-table/cuesheet-table-elements/cuesheetColsFactory';
|
||||
import CuesheetTable from './cuesheet-table/CuesheetTable';
|
||||
import CuesheetTableWrapper from './CuesheetTableWrapper';
|
||||
|
||||
import styles from './CuesheetPage.module.scss';
|
||||
|
||||
export default function CuesheetPage() {
|
||||
const { data: flatRundown, status: rundownStatus } = useFlatRundown();
|
||||
const { data: customFields, status: customFieldStatus } = useCustomFields();
|
||||
const { isViewLocked } = useViewEditor({ isLockable: true });
|
||||
const [isMenuOpen, menuHandler] = useDisclosure();
|
||||
const columns = useMemo(() => makeCuesheetColumns(customFields), [customFields]);
|
||||
|
||||
useWindowTitle('Cuesheet');
|
||||
|
||||
const isLoading = !customFields || !flatRundown || rundownStatus === 'pending' || customFieldStatus === 'pending';
|
||||
|
||||
return (
|
||||
<>
|
||||
<NavigationMenu isOpen={isMenuOpen} onClose={menuHandler.close} />
|
||||
@@ -43,9 +32,7 @@ export default function CuesheetPage() {
|
||||
)}
|
||||
</CuesheetOverview>
|
||||
<CuesheetProgress />
|
||||
<CuesheetDnd columns={columns}>
|
||||
{isLoading ? <EmptyPage text='Loading...' /> : <CuesheetTable data={flatRundown} columns={columns} />}
|
||||
</CuesheetDnd>
|
||||
<CuesheetTableWrapper />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { memo, useMemo } from 'react';
|
||||
import { useSessionStorage } from '@mantine/hooks';
|
||||
|
||||
import EmptyPage from '../../common/components/state/EmptyPage';
|
||||
import useCustomFields from '../../common/hooks-query/useCustomFields';
|
||||
import { useFlatRundown } from '../../common/hooks-query/useRundown';
|
||||
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';
|
||||
|
||||
export default memo(CuesheetTableWrapper);
|
||||
function CuesheetTableWrapper() {
|
||||
const { data: flatRundown, status: rundownStatus } = useFlatRundown();
|
||||
const { data: customFields, status: customFieldStatus } = useCustomFields();
|
||||
|
||||
const [cuesheetMode] = useSessionStorage({
|
||||
key: sessionKeys.cuesheetMode,
|
||||
defaultValue: AppMode.Edit,
|
||||
});
|
||||
const columns = useMemo(() => makeCuesheetColumns(customFields, cuesheetMode), [customFields, cuesheetMode]);
|
||||
const isLoading = !customFields || !flatRundown || rundownStatus === 'pending' || customFieldStatus === 'pending';
|
||||
|
||||
return (
|
||||
<CuesheetDnd columns={columns}>
|
||||
{isLoading ? <EmptyPage text='Loading...' /> : <CuesheetTable data={flatRundown} columns={columns} />}
|
||||
</CuesheetDnd>
|
||||
);
|
||||
}
|
||||
@@ -40,7 +40,7 @@ export default function BlockRow({ blockId, colour, hidePast, rowId, rowIndex, t
|
||||
<td className={style.actionColumn} tabIndex={-1} role='cell'>
|
||||
<IconButton
|
||||
aria-label='Options'
|
||||
variant='subtle-white'
|
||||
variant='ghosted-white'
|
||||
size='small'
|
||||
onClick={(e) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
|
||||
+5
-14
@@ -13,8 +13,8 @@ import {
|
||||
import { colourToHex, cssOrHexToColour } from 'ontime-utils';
|
||||
|
||||
import { RUNDOWN } from '../../../../common/api/constants';
|
||||
import EmptyTableBody from '../../../../common/components/state/EmptyTableBody';
|
||||
import { useSelectedEventId } from '../../../../common/hooks/useSocket';
|
||||
import { lazyEvaluate } from '../../../../common/utils/lazyEvaluate';
|
||||
import { getAccessibleColour } from '../../../../common/utils/styleUtils';
|
||||
import { usePersistedCuesheetOptions } from '../../cuesheet.options';
|
||||
|
||||
@@ -36,17 +36,6 @@ export default function CuesheetBody({ rowModel, selectedRef, table }: CuesheetB
|
||||
const hidePast = usePersistedCuesheetOptions((state) => state.hidePast);
|
||||
const hideDelays = usePersistedCuesheetOptions((state) => state.hideDelays);
|
||||
|
||||
const getVisibleColumns = lazyEvaluate(() => table.getVisibleFlatColumns());
|
||||
const getColumnHash = lazyEvaluate(() => {
|
||||
let columnHash = '';
|
||||
const columns = getVisibleColumns();
|
||||
|
||||
for (let i = 0; i < columns.length; i++) {
|
||||
columnHash += `${columns[i].getIndex()}-${columns[i].getSize()} `;
|
||||
}
|
||||
return columnHash;
|
||||
});
|
||||
|
||||
let eventIndex = 0;
|
||||
// for the first event, it will be past if there is something selected
|
||||
let isPast = Boolean(selectedEventId);
|
||||
@@ -59,6 +48,10 @@ export default function CuesheetBody({ rowModel, selectedRef, table }: CuesheetB
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (rowModel.rows.length === 0) {
|
||||
return <EmptyTableBody text='No data in rundown' />;
|
||||
}
|
||||
|
||||
return (
|
||||
<tbody>
|
||||
{rowModel.rows.map((row, index) => {
|
||||
@@ -140,7 +133,6 @@ export default function CuesheetBody({ rowModel, selectedRef, table }: CuesheetB
|
||||
if (isOntimeEvent(entry)) {
|
||||
eventIndex++;
|
||||
const isSelected = key === selectedEventId;
|
||||
const columnHash = getColumnHash();
|
||||
|
||||
if (isPast && hidePast) {
|
||||
return null;
|
||||
@@ -185,7 +177,6 @@ export default function CuesheetBody({ rowModel, selectedRef, table }: CuesheetB
|
||||
parentBgColour={parentBgColour}
|
||||
table={table}
|
||||
firstAfterBlock={firstAfterBlock}
|
||||
columnHash={columnHash}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+8
-4
@@ -1,3 +1,4 @@
|
||||
import { CSSProperties } from 'react';
|
||||
import { horizontalListSortingStrategy, SortableContext } from '@dnd-kit/sortable';
|
||||
import { useSessionStorage } from '@mantine/hooks';
|
||||
import { flexRender, HeaderGroup } from '@tanstack/react-table';
|
||||
@@ -37,13 +38,16 @@ export default function CuesheetHeader({ headerGroups }: CuesheetHeaderProps) {
|
||||
)}
|
||||
<SortableContext key={key} items={headerGroup.headers} strategy={horizontalListSortingStrategy}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
// @ts-expect-error -- we inject this into react-table
|
||||
const customBackground = header.column.columnDef?.meta?.colour;
|
||||
const customBackground = header.column.columnDef.meta?.colour;
|
||||
const canWrite = header.column.columnDef.meta?.canWrite;
|
||||
|
||||
let customStyles = {};
|
||||
const customStyles: CSSProperties = {
|
||||
opacity: canWrite ? 1 : 0.6,
|
||||
};
|
||||
if (customBackground) {
|
||||
const customColour = getAccessibleColour(customBackground);
|
||||
customStyles = { backgroundColor: customColour.backgroundColor, color: customColour.color };
|
||||
customStyles.backgroundColor = customColour.backgroundColor;
|
||||
customStyles.color = customColour.color;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
+1
@@ -3,6 +3,7 @@
|
||||
.eventRow {
|
||||
background: color-mix(in srgb, transparent 92%, var(--user-bg, $gray-500) 8%);
|
||||
border-left: 4px solid var(--user-bg, $gray-500);
|
||||
min-height: 2.5rem;
|
||||
|
||||
&:hover {
|
||||
outline: 1px solid $blue-500;
|
||||
|
||||
@@ -28,8 +28,6 @@ interface EventRowProps {
|
||||
rowBgColour?: string;
|
||||
parentBgColour?: string;
|
||||
table: Table<OntimeEntry>;
|
||||
/** hack to force re-rendering of the row when the column sizes change */
|
||||
columnHash: string;
|
||||
firstAfterBlock: boolean;
|
||||
}
|
||||
|
||||
@@ -95,7 +93,7 @@ export default function EventRow({
|
||||
<td className={style.actionColumn} tabIndex={-1} role='cell'>
|
||||
<IconButton
|
||||
aria-label='Options'
|
||||
variant='subtle-white'
|
||||
variant='ghosted-white'
|
||||
size='small'
|
||||
onClick={(e) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
|
||||
+4
@@ -16,4 +16,8 @@
|
||||
background-color: $gray-1250;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.indexColumn {
|
||||
color: $label-gray;
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -52,7 +52,7 @@ export default function MilestoneRow({
|
||||
<td className={style.actionColumn} tabIndex={-1} role='cell'>
|
||||
<IconButton
|
||||
aria-label='Options'
|
||||
variant='subtle-white'
|
||||
variant='ghosted-white'
|
||||
size='small'
|
||||
onClick={(e) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
|
||||
+1
-2
@@ -15,14 +15,13 @@ interface SortableCellProps {
|
||||
export function SortableCell({ header, injectedStyles, children }: SortableCellProps) {
|
||||
const { column, colSpan } = header;
|
||||
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({
|
||||
id: column.id,
|
||||
});
|
||||
|
||||
// build drag styles
|
||||
const dragStyle = {
|
||||
...injectedStyles,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
transform: CSS.Translate.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
+121
-76
@@ -5,6 +5,7 @@ import { millisToString } from 'ontime-utils';
|
||||
|
||||
import DelayIndicator from '../../../../common/components/delay-indicator/DelayIndicator';
|
||||
import { formatDuration, formatTime } from '../../../../common/utils/time';
|
||||
import { AppMode } from '../../../../ontimeConfig';
|
||||
|
||||
import DurationInput from './DurationInput';
|
||||
import EditableImage from './EditableImage';
|
||||
@@ -14,7 +15,7 @@ import MutedText from './MutedText';
|
||||
import SingleLineCell from './SingleLineCell';
|
||||
import TimeInput from './TimeInput';
|
||||
|
||||
function MakeStart({ getValue, row, table }: CellContext<OntimeEntry, unknown>) {
|
||||
function MakeStart({ getValue, row, table, column }: CellContext<OntimeEntry, unknown>) {
|
||||
if (!table.options.meta) {
|
||||
return null;
|
||||
}
|
||||
@@ -33,11 +34,18 @@ function MakeStart({ getValue, row, table }: CellContext<OntimeEntry, unknown>)
|
||||
|
||||
const startTime = getValue() as number;
|
||||
const isStartLocked = !event.linkStart;
|
||||
|
||||
const displayTime = showDelayedTimes ? startTime + event.delay : startTime;
|
||||
|
||||
const formattedTime = formatTime(displayTime, formatOpts);
|
||||
|
||||
const canWrite = column.columnDef.meta?.canWrite;
|
||||
if (!canWrite) {
|
||||
return (
|
||||
<MutedText numeric>
|
||||
{formattedTime}
|
||||
<DelayIndicator delayValue={event.delay} tooltipPrefix={millisToString(startTime)} />
|
||||
</MutedText>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<TimeInput initialValue={startTime} onSubmit={update} lockedValue={isStartLocked} delayed={event.delay !== 0}>
|
||||
{formattedTime}
|
||||
@@ -46,7 +54,7 @@ function MakeStart({ getValue, row, table }: CellContext<OntimeEntry, unknown>)
|
||||
);
|
||||
}
|
||||
|
||||
function MakeEnd({ getValue, row, table }: CellContext<OntimeEntry, unknown>) {
|
||||
function MakeEnd({ getValue, row, table, column }: CellContext<OntimeEntry, unknown>) {
|
||||
if (!table.options.meta) {
|
||||
return null;
|
||||
}
|
||||
@@ -65,11 +73,19 @@ function MakeEnd({ getValue, row, table }: CellContext<OntimeEntry, unknown>) {
|
||||
|
||||
const endTime = getValue() as number;
|
||||
const isEndLocked = event.timeStrategy === TimeStrategy.LockEnd;
|
||||
|
||||
const displayTime = showDelayedTimes ? endTime + event.delay : endTime;
|
||||
|
||||
const formattedTime = formatTime(displayTime, formatOpts);
|
||||
|
||||
const canWrite = column.columnDef.meta?.canWrite;
|
||||
if (!canWrite) {
|
||||
return (
|
||||
<MutedText numeric>
|
||||
{formattedTime}
|
||||
<DelayIndicator delayValue={event.delay} tooltipPrefix={millisToString(endTime)} />
|
||||
</MutedText>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TimeInput initialValue={endTime} onSubmit={update} lockedValue={isEndLocked} delayed={event.delay !== 0}>
|
||||
{formattedTime}
|
||||
@@ -78,7 +94,7 @@ function MakeEnd({ getValue, row, table }: CellContext<OntimeEntry, unknown>) {
|
||||
);
|
||||
}
|
||||
|
||||
function MakeDuration({ getValue, row, table }: CellContext<OntimeEntry, unknown>) {
|
||||
function MakeDuration({ getValue, row, table, column }: CellContext<OntimeEntry, unknown>) {
|
||||
if (!table.options.meta) {
|
||||
return null;
|
||||
}
|
||||
@@ -97,6 +113,11 @@ function MakeDuration({ getValue, row, table }: CellContext<OntimeEntry, unknown
|
||||
const isDurationLocked = event.timeStrategy === TimeStrategy.LockDuration;
|
||||
const formattedDuration = formatDuration(duration, hideTableSeconds);
|
||||
|
||||
const canWrite = column.columnDef.meta?.canWrite;
|
||||
if (!canWrite) {
|
||||
return <MutedText numeric>{formattedDuration}</MutedText>;
|
||||
}
|
||||
|
||||
return (
|
||||
<DurationInput initialValue={duration} onSubmit={update} lockedValue={isDurationLocked}>
|
||||
{formattedDuration}
|
||||
@@ -182,78 +203,102 @@ function MakeCustomField({ row, column, table }: CellContext<OntimeEntry, unknow
|
||||
return <MultiLineCell initialValue={initialValue} handleUpdate={update} />;
|
||||
}
|
||||
|
||||
export function makeCuesheetColumns(customFields: CustomFields): ColumnDef<OntimeEntry>[] {
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
const dynamicCustomFields = Object.keys(customFields).map((key) => ({
|
||||
accessorKey: key,
|
||||
id: key,
|
||||
header: customFields[key].label,
|
||||
meta: { colour: customFields[key].colour, type: customFields[key].type },
|
||||
cell: customFields[key].type === 'text' ? MakeCustomField : LazyImage,
|
||||
/**
|
||||
* 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>[] {
|
||||
const columnsDef: ColumnDef<OntimeEntry>[] = [];
|
||||
const modeAllowsWrite = cuesheetMode === AppMode.Edit;
|
||||
|
||||
columnsDef.push({
|
||||
accessorKey: 'flag',
|
||||
id: 'flag',
|
||||
header: 'Flag',
|
||||
cell: MakeFlagField,
|
||||
size: 45,
|
||||
minSize: 45,
|
||||
meta: { canWrite: modeAllowsWrite },
|
||||
});
|
||||
|
||||
columnsDef.push({
|
||||
accessorKey: 'cue',
|
||||
id: 'cue',
|
||||
header: 'Cue',
|
||||
cell: MakeSingleLineField,
|
||||
size: 75,
|
||||
minSize: 40,
|
||||
meta: { canWrite: modeAllowsWrite },
|
||||
});
|
||||
|
||||
columnsDef.push({
|
||||
accessorKey: 'timeStart',
|
||||
id: 'timeStart',
|
||||
header: 'Start',
|
||||
cell: MakeStart,
|
||||
size: 75,
|
||||
minSize: 75,
|
||||
meta: { canWrite: modeAllowsWrite },
|
||||
});
|
||||
|
||||
columnsDef.push({
|
||||
accessorKey: 'timeEnd',
|
||||
id: 'timeEnd',
|
||||
header: 'End',
|
||||
cell: MakeEnd,
|
||||
size: 75,
|
||||
minSize: 75,
|
||||
meta: { canWrite: modeAllowsWrite },
|
||||
});
|
||||
|
||||
columnsDef.push({
|
||||
accessorKey: 'duration',
|
||||
id: 'duration',
|
||||
header: 'Duration',
|
||||
cell: MakeDuration,
|
||||
size: 75,
|
||||
minSize: 75,
|
||||
meta: { canWrite: modeAllowsWrite },
|
||||
});
|
||||
|
||||
columnsDef.push({
|
||||
accessorKey: 'title',
|
||||
id: 'title',
|
||||
header: 'Title',
|
||||
cell: MakeSingleLineField,
|
||||
size: 250,
|
||||
minSize: 75,
|
||||
}));
|
||||
meta: { canWrite: modeAllowsWrite },
|
||||
});
|
||||
|
||||
return [
|
||||
{
|
||||
accessorKey: 'flag',
|
||||
id: 'flag',
|
||||
header: 'Flag',
|
||||
cell: MakeFlagField,
|
||||
size: 45,
|
||||
minSize: 45,
|
||||
},
|
||||
{
|
||||
accessorKey: 'cue',
|
||||
id: 'cue',
|
||||
header: 'Cue',
|
||||
cell: MakeSingleLineField,
|
||||
size: 75,
|
||||
minSize: 40,
|
||||
},
|
||||
{
|
||||
accessorKey: 'timeStart',
|
||||
id: 'timeStart',
|
||||
header: 'Start',
|
||||
cell: MakeStart,
|
||||
size: 75,
|
||||
minSize: 75,
|
||||
},
|
||||
{
|
||||
accessorKey: 'timeEnd',
|
||||
id: 'timeEnd',
|
||||
header: 'End',
|
||||
cell: MakeEnd,
|
||||
size: 75,
|
||||
minSize: 75,
|
||||
},
|
||||
{
|
||||
accessorKey: 'duration',
|
||||
id: 'duration',
|
||||
header: 'Duration',
|
||||
cell: MakeDuration,
|
||||
size: 75,
|
||||
minSize: 75,
|
||||
},
|
||||
{
|
||||
accessorKey: 'title',
|
||||
id: 'title',
|
||||
header: 'Title',
|
||||
cell: MakeSingleLineField,
|
||||
columnsDef.push({
|
||||
accessorKey: 'note',
|
||||
id: 'note',
|
||||
header: 'Note',
|
||||
cell: MakeMultiLineField,
|
||||
size: 250,
|
||||
minSize: 75,
|
||||
meta: { canWrite: modeAllowsWrite },
|
||||
});
|
||||
|
||||
// custom fields at the end
|
||||
const customFieldKeys = Object.keys(customFields);
|
||||
|
||||
for (let i = 0; i < customFieldKeys.length; i++) {
|
||||
const key = customFieldKeys[i];
|
||||
columnsDef.push({
|
||||
accessorKey: key,
|
||||
id: key,
|
||||
header: customFields[key].label,
|
||||
cell: customFields[key].type === 'text' ? MakeCustomField : LazyImage,
|
||||
size: 250,
|
||||
minSize: 75,
|
||||
},
|
||||
{
|
||||
accessorKey: 'note',
|
||||
id: 'note',
|
||||
header: 'Note',
|
||||
cell: MakeMultiLineField,
|
||||
size: 250,
|
||||
minSize: 75,
|
||||
},
|
||||
...dynamicCustomFields,
|
||||
];
|
||||
meta: {
|
||||
colour: customFields[key].colour,
|
||||
canWrite: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return columnsDef;
|
||||
}
|
||||
|
||||
+2
-3
@@ -1,4 +1,4 @@
|
||||
import { memo, ReactNode } from 'react';
|
||||
import { ReactNode } 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';
|
||||
@@ -27,8 +27,7 @@ interface CuesheetTableSettingsProps {
|
||||
handleClearToggles: () => void;
|
||||
}
|
||||
|
||||
export default memo(CuesheetTableSettings);
|
||||
function CuesheetTableSettings({
|
||||
export default function CuesheetTableSettings({
|
||||
columns,
|
||||
handleResetResizing,
|
||||
handleResetReordering,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useMemo } from 'react';
|
||||
import { CustomFields, MessageState, OntimeEvent, ProjectData, Settings, ViewSettings } from 'ontime-types';
|
||||
|
||||
import { FitText } from '../../common/components/fit-text/FitText';
|
||||
@@ -146,7 +147,7 @@ export default function Timer({
|
||||
|
||||
// gather option data
|
||||
const defaultFormat = getDefaultFormat(settings?.timeFormat);
|
||||
const timerOptions = getTimerOptions(defaultFormat, customFields);
|
||||
const timerOptions = useMemo(() => getTimerOptions(defaultFormat, customFields), [customFields, defaultFormat]);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ontime-electron",
|
||||
"version": "4.0.0-alpha.0",
|
||||
"version": "4.0.0-alpha.1",
|
||||
"author": "Carlos Valente",
|
||||
"description": "Time keeping for live events",
|
||||
"repository": "https://github.com/cpvalente/ontime",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "ontime-server",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"version": "4.0.0-alpha.0",
|
||||
"version": "4.0.0-alpha.1",
|
||||
"exports": "./src/index.js",
|
||||
"dependencies": {
|
||||
"@googleapis/sheets": "^5.0.5",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { DatabaseModel, CustomFields, CustomField } from 'ontime-types';
|
||||
import { isAlphanumericWithSpace, customFieldLabelToKey } from 'ontime-utils';
|
||||
import { checkRegex, customFieldLabelToKey } from 'ontime-utils';
|
||||
|
||||
import type { ErrorEmitter } from '../../utils/parserUtils.js';
|
||||
|
||||
@@ -29,7 +29,7 @@ export function sanitiseCustomFields(data: object): CustomFields {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isAlphanumericWithSpace(field.label)) {
|
||||
if (!checkRegex.isAlphanumericWithSpace(field.label)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { isAlphanumericWithSpace } from 'ontime-utils';
|
||||
import { checkRegex } from 'ontime-utils';
|
||||
|
||||
import { body, param } from 'express-validator';
|
||||
import { requestValidationFunction } from '../validation-utils/validationFunction.js';
|
||||
@@ -9,7 +9,7 @@ export const validateCustomField = [
|
||||
.trim()
|
||||
.notEmpty()
|
||||
.custom((value) => {
|
||||
return isAlphanumericWithSpace(value);
|
||||
return checkRegex.isAlphanumericWithSpace(value);
|
||||
}),
|
||||
body('type').isIn(['text', 'image']),
|
||||
body('colour').isString().trim(),
|
||||
@@ -24,7 +24,7 @@ export const validateEditCustomField = [
|
||||
.trim()
|
||||
.notEmpty()
|
||||
.custom((value) => {
|
||||
return isAlphanumericWithSpace(value);
|
||||
return checkRegex.isAlphanumericWithSpace(value);
|
||||
}),
|
||||
body('type').isIn(['text', 'image']),
|
||||
body('colour').isString().trim(),
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
validateTimerType,
|
||||
validateEndAction,
|
||||
customFieldLabelToKey,
|
||||
isAlphanumericWithSpace,
|
||||
checkRegex,
|
||||
} from 'ontime-utils';
|
||||
|
||||
import { Merge } from 'ts-essentials';
|
||||
@@ -325,7 +325,7 @@ export function getCustomFieldData(
|
||||
|
||||
for (const ontimeLabel in importMap.custom) {
|
||||
// if the label is not valid, we skip the import
|
||||
if (!isAlphanumericWithSpace(ontimeLabel)) {
|
||||
if (!checkRegex.isAlphanumericWithSpace(ontimeLabel)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ export const projectSanitiser = [
|
||||
body('custom').optional().isArray(),
|
||||
body('custom.*.title').optional().isString().trim().notEmpty(),
|
||||
body('custom.*.value').optional().isString().trim().notEmpty(),
|
||||
body('custom.*.url').optional().isString().trim().notEmpty(),
|
||||
body('custom.*.url').optional().isString().trim(),
|
||||
|
||||
requestValidationFunction,
|
||||
];
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
isOntimeEvent,
|
||||
OntimeBlock,
|
||||
OntimeEntry,
|
||||
OntimeEvent,
|
||||
PatchWithId,
|
||||
RefetchKey,
|
||||
Rundown,
|
||||
@@ -35,14 +34,28 @@ export async function addEntry(eventData: EventPostPayload): Promise<OntimeEntry
|
||||
throw new Error(`Event with ID ${eventData.id} already exists`);
|
||||
}
|
||||
|
||||
// if the user provides a parent (inside a group), we make sure it exists and it is a group
|
||||
// the parent can be provided or inferred from position
|
||||
let parent: OntimeBlock | null = null;
|
||||
|
||||
if ('parent' in eventData && eventData.parent != null) {
|
||||
// if the user provides a parent (inside a group), we make sure it exists and it is a group
|
||||
const maybeParent = rundown.entries[eventData.parent];
|
||||
if (!maybeParent || !isOntimeBlock(maybeParent)) {
|
||||
throw new Error(`Invalid parent event with ID ${eventData.parent}`);
|
||||
}
|
||||
parent = maybeParent;
|
||||
} else {
|
||||
// otherwise, we may infer the parent from relative positioning (after/before)
|
||||
const referenceId = eventData?.after ?? eventData?.before;
|
||||
if (referenceId) {
|
||||
const maybeSibling = rundown.entries[referenceId];
|
||||
if (maybeSibling && 'parent' in maybeSibling && maybeSibling.parent) {
|
||||
const maybeParent = rundown.entries[maybeSibling.parent];
|
||||
if (maybeParent && isOntimeBlock(maybeParent)) {
|
||||
parent = maybeParent;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// normalise the position of the event in the rundown order
|
||||
@@ -87,11 +100,6 @@ export async function editEntry(patch: PatchWithId): Promise<OntimeEntry> {
|
||||
throw new Error('Entry not found');
|
||||
}
|
||||
|
||||
// we dont allow the user to change the cue to empty string
|
||||
if ((patch as Partial<OntimeEvent>)?.cue === '') {
|
||||
throw new Error('Cue value invalid');
|
||||
}
|
||||
|
||||
// we cannot allow patching to a different type
|
||||
if (patch?.type && currentEntry.type !== patch.type) {
|
||||
throw new Error('Invalid event type');
|
||||
|
||||
@@ -12,13 +12,14 @@ describe('parseUrlPresets()', () => {
|
||||
|
||||
it('parses data, skipping invalid results', () => {
|
||||
const errorEmitter = vi.fn();
|
||||
const urlPresets = [{ enabled: true, alias: 'alias', pathAndParams: 'ss' }] as URLPreset[];
|
||||
const urlPresets = [{ enabled: true, alias: 'alias', target: 'timer', search: 'ss' }] as URLPreset[];
|
||||
const result = parseUrlPresets({ urlPresets }, errorEmitter);
|
||||
expect(result.length).toEqual(1);
|
||||
expect(result.at(0)).toMatchObject({
|
||||
enabled: true,
|
||||
alias: 'alias',
|
||||
pathAndParams: 'ss',
|
||||
target: 'timer',
|
||||
search: 'ss',
|
||||
});
|
||||
expect(errorEmitter).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -33,7 +34,8 @@ describe('parseUrlPresets()', () => {
|
||||
{
|
||||
enabled: false,
|
||||
alias: 'testalias',
|
||||
pathAndParams: 'testpathAndParams',
|
||||
target: 'timer',
|
||||
search: 'testpathAndParams',
|
||||
},
|
||||
],
|
||||
} as unknown as DatabaseModel;
|
||||
|
||||
@@ -16,10 +16,16 @@ export function parseUrlPresets(data: Partial<DatabaseModel>, emitError?: ErrorE
|
||||
const newPresets: URLPreset[] = [];
|
||||
|
||||
for (const preset of data.urlPresets) {
|
||||
if (!preset.alias || !preset.search || !preset.target) {
|
||||
emitError?.(`Invalid URL preset: ${JSON.stringify(preset)}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const newPreset = {
|
||||
enabled: preset.enabled ?? false,
|
||||
alias: preset.alias ?? '',
|
||||
pathAndParams: preset.pathAndParams ?? '',
|
||||
alias: preset.alias,
|
||||
target: preset.target,
|
||||
search: preset.search,
|
||||
};
|
||||
newPresets.push(newPreset);
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@ import express from 'express';
|
||||
import type { Request, Response } from 'express';
|
||||
import type { ErrorResponse, URLPreset } from 'ontime-types';
|
||||
import { getErrorMessage } from 'ontime-utils';
|
||||
import { validateUrlPresets } from './urlPresets.validation.js';
|
||||
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
|
||||
import { validateNewPreset, validatePresetParam, validateUpdatePreset } from './urlPresets.validation.js';
|
||||
|
||||
export const router = express.Router();
|
||||
|
||||
@@ -12,13 +12,64 @@ router.get('/', (_req: Request, res: Response<URLPreset[]>) => {
|
||||
res.status(200).send(presets as URLPreset[]);
|
||||
});
|
||||
|
||||
router.post('/', validateUrlPresets, async (req: Request, res: Response<URLPreset[] | ErrorResponse>) => {
|
||||
router.post('/', validateNewPreset, async (req: Request, res: Response<URLPreset[] | ErrorResponse>) => {
|
||||
try {
|
||||
const newPresets: URLPreset[] = req.body.map((preset: URLPreset) => ({
|
||||
enabled: preset.enabled,
|
||||
alias: preset.alias,
|
||||
pathAndParams: preset.pathAndParams,
|
||||
}));
|
||||
const newPreset: URLPreset = {
|
||||
enabled: req.body.enabled,
|
||||
alias: req.body.alias,
|
||||
target: req.body.target,
|
||||
search: req.body.search,
|
||||
};
|
||||
|
||||
const currentPresets = getDataProvider().getUrlPresets();
|
||||
if (currentPresets.some((preset) => preset.alias === newPreset.alias)) {
|
||||
throw new Error(`Preset with alias "${newPreset.alias}" already exists.`);
|
||||
}
|
||||
|
||||
const newPresets = [...currentPresets, newPreset];
|
||||
|
||||
// Update the URL presets in the data provider
|
||||
await getDataProvider().setUrlPresets(newPresets);
|
||||
res.status(201).send(newPresets);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
res.status(400).send({ message });
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/:alias', validateUpdatePreset, async (req: Request, res: Response<URLPreset[] | ErrorResponse>) => {
|
||||
try {
|
||||
const alias = req.params.alias;
|
||||
const updatedPreset: URLPreset = {
|
||||
enabled: req.body.enabled,
|
||||
alias: req.body.alias,
|
||||
target: req.body.target,
|
||||
search: req.body.search,
|
||||
};
|
||||
|
||||
if (alias !== updatedPreset.alias) {
|
||||
throw new Error(`Changing alias is not permitted`);
|
||||
}
|
||||
|
||||
const currentPresets = getDataProvider().getUrlPresets();
|
||||
const newPresets = currentPresets.map((preset) => (preset.alias === alias ? updatedPreset : preset));
|
||||
|
||||
// Update the URL presets in the data provider
|
||||
await getDataProvider().setUrlPresets(newPresets);
|
||||
res.status(200).send(newPresets);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
res.status(400).send({ message });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/:alias', validatePresetParam, async (req: Request, res: Response<URLPreset[] | ErrorResponse>) => {
|
||||
try {
|
||||
const alias = req.params.alias;
|
||||
const currentPresets = getDataProvider().getUrlPresets();
|
||||
const newPresets = currentPresets.filter((preset) => preset.alias !== alias);
|
||||
|
||||
// Update the URL presets in the data provider
|
||||
await getDataProvider().setUrlPresets(newPresets);
|
||||
res.status(200).send(newPresets);
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,14 +1,31 @@
|
||||
import { body } from 'express-validator';
|
||||
import { OntimeView } from 'ontime-types';
|
||||
|
||||
import { body, param } from 'express-validator';
|
||||
|
||||
import { requestValidationFunction } from '../validation-utils/validationFunction.js';
|
||||
|
||||
/**
|
||||
* validate array of URL preset objects
|
||||
*/
|
||||
export const validateUrlPresets = [
|
||||
body().isArray().withMessage('No array found in request'),
|
||||
body('*.enabled').isBoolean(),
|
||||
body('*.alias').isString().trim().notEmpty(),
|
||||
body('*.pathAndParams').isString().trim().notEmpty(),
|
||||
export const validateNewPreset = [
|
||||
body().isObject().withMessage('No data found in request'),
|
||||
body('enabled').isBoolean(),
|
||||
body('alias').isString().trim().notEmpty(),
|
||||
body('target').isString().trim().notEmpty().isIn(Object.values(OntimeView)),
|
||||
body('search').isString().trim(),
|
||||
|
||||
requestValidationFunction,
|
||||
];
|
||||
|
||||
export const validateUpdatePreset = [
|
||||
param('alias').isString().trim().notEmpty(),
|
||||
body().isObject().withMessage('No data found in request'),
|
||||
body('enabled').isBoolean(),
|
||||
body('alias').isString().trim().notEmpty(),
|
||||
body('target').isString().trim().notEmpty().isIn(Object.values(OntimeView)),
|
||||
body('search').isString().trim(),
|
||||
|
||||
requestValidationFunction,
|
||||
];
|
||||
|
||||
export const validatePresetParam = [param('alias').isString().trim().notEmpty(), requestValidationFunction];
|
||||
|
||||
@@ -88,8 +88,8 @@ describe('safeMerge', () => {
|
||||
it('should merge the urlPresets key when present', () => {
|
||||
const newData = {
|
||||
urlPresets: [
|
||||
{ enabled: true, alias: 'alias1', pathAndParams: '' },
|
||||
{ enabled: true, alias: 'alias2', pathAndParams: '' },
|
||||
{ enabled: true, alias: 'alias1', search: '' },
|
||||
{ enabled: true, alias: 'alias2', search: '' },
|
||||
] as URLPreset[],
|
||||
};
|
||||
|
||||
|
||||
@@ -553,14 +553,16 @@ export const demoDb: DatabaseModel = {
|
||||
{
|
||||
enabled: true,
|
||||
alias: 'clock',
|
||||
pathAndParams:
|
||||
'timer?showLeadingZeros=true&timerType=clock&hideClock=true&hideCards=true&hideProgress=true&hideMessage=true&hideSecondary=true&hideLogo=true',
|
||||
target: 'timer',
|
||||
search:
|
||||
'showLeadingZeros=true&timerType=clock&hideClock=true&hideCards=true&hideProgress=true&hideMessage=true&hideSecondary=true&hideLogo=true',
|
||||
},
|
||||
{
|
||||
enabled: true,
|
||||
alias: 'minimal',
|
||||
pathAndParams:
|
||||
'timer?showLeadingZeros=true&hideClock=true&hideCards=true&hideProgress=true&hideMessage=true&hideSecondary=true&hideLogo=true',
|
||||
target: 'timer',
|
||||
search:
|
||||
'showLeadingZeros=true&hideClock=true&hideCards=true&hideProgress=true&hideMessage=true&hideSecondary=true&hideLogo=true',
|
||||
},
|
||||
],
|
||||
automation: {
|
||||
|
||||
@@ -737,7 +737,12 @@ export function loadNextFlag(currentIndex: number, rundown: Rundown, metadata: R
|
||||
return;
|
||||
}
|
||||
|
||||
for (let i = currentIndex; i < metadata.timedEventOrder.length; i++) {
|
||||
// we are in the last event
|
||||
if (currentIndex + 1 >= metadata.timedEventOrder.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (let i = currentIndex + 1; i < metadata.timedEventOrder.length; i++) {
|
||||
const entryId = metadata.timedEventOrder[i];
|
||||
if (metadata.flags.includes(entryId)) {
|
||||
const event = rundown.entries[entryId];
|
||||
|
||||
@@ -488,12 +488,14 @@
|
||||
{
|
||||
"enabled": true,
|
||||
"alias": "clock",
|
||||
"pathAndParams": "timer?showLeadingZeros=true&timerType=clock&hideClock=true&hideCards=true&hideProgress=true&hideMessage=true&hideSecondary=true&hideLogo=true"
|
||||
"target": "timer",
|
||||
"search": "timer?showLeadingZeros=true&timerType=clock&hideClock=true&hideCards=true&hideProgress=true&hideMessage=true&hideSecondary=true&hideLogo=true"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"alias": "minimal",
|
||||
"pathAndParams": "timer?showLeadingZeros=true&hideClock=true&hideCards=true&hideProgress=true&hideMessage=true&hideSecondary=true&hideLogo=true"
|
||||
"target": "timer",
|
||||
"search": "timer?showLeadingZeros=true&hideClock=true&hideCards=true&hideProgress=true&hideMessage=true&hideSecondary=true&hideLogo=true"
|
||||
}
|
||||
],
|
||||
"automation": {
|
||||
|
||||
@@ -8,16 +8,19 @@ test('URL preset feature, it should redirect to given URL', async ({ page }) =>
|
||||
await page.getByRole('button', { name: 'URL Presets' }).click();
|
||||
|
||||
// create preset
|
||||
await page.getByTestId('url-preset-form').getByRole('button', { name: 'New' }).scrollIntoViewIfNeeded();
|
||||
await page.getByTestId('url-preset-form').getByRole('button', { name: 'New' }).click();
|
||||
await page.getByRole('heading', { name: 'URL presets New' }).getByRole('button').scrollIntoViewIfNeeded();
|
||||
await page.getByRole('heading', { name: 'URL presets New' }).getByRole('button').click();
|
||||
|
||||
await page.getByTestId('field__alias_0').click();
|
||||
await page.getByTestId('field__alias_0').fill('testing');
|
||||
await page.locator('input[name="alias"]').click();
|
||||
await page.locator('input[name="alias"]').fill('testing');
|
||||
|
||||
await page.getByTestId('field__url_0').click();
|
||||
await page.getByTestId('field__url_0').fill('countdown');
|
||||
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.getByTestId('url-preset-form').getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await page.getByRole('combobox').filter({ hasText: 'Countdown' });
|
||||
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
|
||||
// make sure preset works
|
||||
await page.goto('http://localhost:4001/testing');
|
||||
|
||||
Vendored
+4
-2
@@ -505,12 +505,14 @@
|
||||
{
|
||||
"enabled": true,
|
||||
"alias": "clock",
|
||||
"pathAndParams": "timer?showLeadingZeros=true&timerType=clock&hideClock=true&hideCards=true&hideProgress=true&hideMessage=true&hideSecondary=true&hideLogo=true"
|
||||
"target": "timer",
|
||||
"search": "timer?showLeadingZeros=true&timerType=clock&hideClock=true&hideCards=true&hideProgress=true&hideMessage=true&hideSecondary=true&hideLogo=true"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"alias": "minimal",
|
||||
"pathAndParams": "timer?showLeadingZeros=true&hideClock=true&hideCards=true&hideProgress=true&hideMessage=true&hideSecondary=true&hideLogo=true"
|
||||
"target": "timer",
|
||||
"search": "timer?showLeadingZeros=true&hideClock=true&hideCards=true&hideProgress=true&hideMessage=true&hideSecondary=true&hideLogo=true"
|
||||
}
|
||||
],
|
||||
"automation": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ontime",
|
||||
"version": "4.0.0-alpha.0",
|
||||
"version": "4.0.0-alpha.1",
|
||||
"description": "Time keeping for live events",
|
||||
"keywords": [
|
||||
"ontime",
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import type { OntimeEntry } from '../../definitions/core/OntimeEntry.js';
|
||||
import type { EntryId, OntimeEntry } from '../../definitions/core/OntimeEntry.js';
|
||||
|
||||
export type PatchWithId<T extends OntimeEntry = OntimeEntry> = Partial<T> & { id: string };
|
||||
export type PatchWithId<T extends OntimeEntry = OntimeEntry> = Partial<T> & { id: EntryId };
|
||||
|
||||
export type EventPostPayload = Partial<OntimeEntry> & {
|
||||
after?: string;
|
||||
before?: string;
|
||||
after?: EntryId;
|
||||
before?: EntryId;
|
||||
};
|
||||
|
||||
export type TransientEventPayload = Partial<OntimeEntry> & {
|
||||
after?: string;
|
||||
before?: string;
|
||||
after?: EntryId;
|
||||
before?: EntryId;
|
||||
};
|
||||
|
||||
export type ProjectRundown = {
|
||||
|
||||
@@ -1,5 +1,22 @@
|
||||
/**
|
||||
* Describes all views in Ontime
|
||||
*/
|
||||
export enum OntimeView {
|
||||
Editor = 'editor',
|
||||
Cuesheet = 'cuesheet',
|
||||
Operator = 'op',
|
||||
Timer = 'timer',
|
||||
Backstage = 'backstage',
|
||||
Timeline = 'timeline',
|
||||
StudioClock = 'studio',
|
||||
Countdown = 'countdown',
|
||||
ProjectInfo = 'info',
|
||||
}
|
||||
|
||||
export type URLPreset = {
|
||||
// presets cannot target the editor view
|
||||
target: Omit<OntimeView, 'editor'>;
|
||||
enabled: boolean;
|
||||
alias: string;
|
||||
pathAndParams: string;
|
||||
search: string;
|
||||
};
|
||||
|
||||
@@ -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 type { URLPreset } from './definitions/core/UrlPreset.type.js';
|
||||
export { OntimeView, type URLPreset } from './definitions/core/UrlPreset.type.js';
|
||||
|
||||
// ---> Custom Fields
|
||||
export type {
|
||||
|
||||
@@ -50,7 +50,7 @@ export {
|
||||
removeTrailingZero,
|
||||
} from './src/date-utils/timeFormatting.js';
|
||||
export { parseUserTime } from './src/date-utils/parseUserTime.js';
|
||||
export { isAlphanumeric, isAlphanumericWithSpace } from './src/regex-utils/isAlphanumeric.js';
|
||||
export { checkRegex, regex } from './src/regex-utils/checkRegex.js';
|
||||
export { isColourHex } from './src/regex-utils/isColourHex.js';
|
||||
export { splitWhitespace } from './src/regex-utils/splitWhitespace.js';
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { checkRegex } from './checkRegex';
|
||||
|
||||
describe('checkRegex()', () => {
|
||||
it('isOnlyNumbers', () => {
|
||||
expect(checkRegex.isOnlyNumbers('12345')).toBe(true);
|
||||
expect(checkRegex.isOnlyNumbers('123a')).toBe(false);
|
||||
});
|
||||
|
||||
it('isIPAddress', () => {
|
||||
expect(checkRegex.isIPAddress('192.168.0.1')).toBe(true);
|
||||
expect(checkRegex.isIPAddress('999.999.999.999')).toBe(false);
|
||||
});
|
||||
|
||||
it('startsWithHttp', () => {
|
||||
expect(checkRegex.startsWithHttp('http://example.com')).toBe(true);
|
||||
expect(checkRegex.startsWithHttp('https://example.com')).toBe(true);
|
||||
expect(checkRegex.startsWithHttp('ftp://example.com')).toBe(false);
|
||||
});
|
||||
|
||||
it('startsWithSlash', () => {
|
||||
expect(checkRegex.startsWithSlash('/path')).toBe(true);
|
||||
expect(checkRegex.startsWithSlash('path')).toBe(false);
|
||||
});
|
||||
|
||||
it('isAlphanumericWithSpace', () => {
|
||||
expect(checkRegex.isAlphanumericWithSpace('abc 123')).toBe(true);
|
||||
expect(checkRegex.isAlphanumericWithSpace('abc-123')).toBe(false);
|
||||
});
|
||||
|
||||
it('isASCII', () => {
|
||||
expect(checkRegex.isASCII('Hello, World!')).toBe(true);
|
||||
expect(checkRegex.isASCII('こんにちは')).toBe(false);
|
||||
});
|
||||
|
||||
it('isASCIIorEmpty', () => {
|
||||
expect(checkRegex.isASCIIorEmpty('')).toBe(true);
|
||||
expect(checkRegex.isASCIIorEmpty('Hello')).toBe(true);
|
||||
expect(checkRegex.isASCIIorEmpty('こんにちは')).toBe(false);
|
||||
});
|
||||
|
||||
it('isNotEmpty', () => {
|
||||
expect(checkRegex.isNotEmpty('Hello')).toBe(true);
|
||||
expect(checkRegex.isNotEmpty('')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
export const regex = {
|
||||
isOnlyNumbers: /^\d+$/,
|
||||
isIPAddress: /^((25[0-5]|(2[0-4]|1\d|[1-9]|)\d)\.?\b){4}$/,
|
||||
startsWithHttp: /^https?:\/\//,
|
||||
startsWithSlash: /^\//,
|
||||
isAlphanumeric: /^[a-z0-9]+$/i,
|
||||
isAlphanumericWithSpace: /^[a-z0-9_ ]+$/i,
|
||||
isASCII: /^[ -~]+$/, // https://catonmat.net/my-favorite-regex
|
||||
isASCIIorEmpty: /^$|^[ -~]+$/, // https://catonmat.net/my-favorite-regex
|
||||
isNotEmpty: /\S/,
|
||||
};
|
||||
|
||||
export const checkRegex = {
|
||||
isOnlyNumbers: (text: string): boolean => regex.isOnlyNumbers.test(text),
|
||||
isIPAddress: (text: string): boolean => regex.isIPAddress.test(text),
|
||||
startsWithHttp: (text: string): boolean => regex.startsWithHttp.test(text),
|
||||
startsWithSlash: (text: string): boolean => regex.startsWithSlash.test(text),
|
||||
isAlphanumeric: (text: string): boolean => regex.isAlphanumeric.test(text),
|
||||
isAlphanumericWithSpace: (text: string): boolean => regex.isAlphanumericWithSpace.test(text),
|
||||
isASCII: (text: string): boolean => regex.isASCII.test(text),
|
||||
isASCIIorEmpty: (text: string): boolean => regex.isASCIIorEmpty.test(text),
|
||||
isNotEmpty: (text: string): boolean => regex.isNotEmpty.test(text),
|
||||
};
|
||||
@@ -1,17 +0,0 @@
|
||||
import { isAlphanumeric } from './isAlphanumeric';
|
||||
|
||||
describe('test isAlphanumeric() function', () => {
|
||||
it('it OK strings', () => {
|
||||
const ts = ['abcdefghijklmnopqrstuvwxyz', '0123456798', '123asd'];
|
||||
for (const s of ts) {
|
||||
expect(isAlphanumeric(s)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('it bad strings', () => {
|
||||
const ts = ['!abcd1234', 'åøæ', '*'];
|
||||
for (const s of ts) {
|
||||
expect(isAlphanumeric(s)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
/**
|
||||
* @description Validates a alphanumeric string
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isAlphanumeric = (text: string): boolean => {
|
||||
const regex = /^[a-z0-9]+$/i;
|
||||
return regex.test(text);
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Validates a alphanumeric string allow space
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isAlphanumericWithSpace = (text: string): boolean => {
|
||||
const regex = /^[a-z0-9_ ]+$/i;
|
||||
return regex.test(text);
|
||||
};
|
||||
Reference in New Issue
Block a user