Compare commits

...

22 Commits

Author SHA1 Message Date
Carlos Valente 3158311aa1 docs: align readme with documentation 2025-07-26 10:51:00 +02:00
Carlos Valente 6b4d96dddd feat: extra info in backstage 2025-07-26 10:51:00 +02:00
Carlos Valente dc3a32d2bc fix: custom project data 2025-07-26 10:51:00 +02:00
Carlos Valente bda01c886e fix: mirroring 2025-07-26 10:51:00 +02:00
Carlos Valente 93916653be refactor: tweaks to styles
- cuesheet action is ghosted
- mute index in cuesheet milestones
- clarify mirrored state in navigation menu
- align overview contents
2025-07-26 10:51:00 +02:00
Carlos Valente 40f8976350 fix: custom fields are last in order 2025-07-26 10:51:00 +02:00
Carlos Valente 78e905a735 fix: flag cannot be the current event 2025-07-26 10:51:00 +02:00
Carlos Valente 126ff61738 refactor: milestone actions in context menu 2025-07-26 10:51:00 +02:00
Carlos Valente 1e63238cac fix: allow empty string as a cue value 2025-07-26 10:51:00 +02:00
Carlos Valente 6ffe34a642 bump version to 4.0.0-alpha.1 2025-07-26 10:51:00 +02:00
Carlos Valente 69f276765b fix: delay indicator styling 2025-07-26 10:51:00 +02:00
Carlos Valente 75554071a7 refactor: improve params performance 2025-07-26 10:51:00 +02:00
Carlos Valente a1fab10bfd feat: improve preset building 2025-07-26 10:51:00 +02:00
Carlos Valente 5bc286145d refactor: extract regex utilities 2025-07-26 10:51:00 +02:00
Carlos Valente 44508ce8b4 refactor: block writing to base fields in run mode 2025-07-26 10:51:00 +02:00
Carlos Valente 90105a199a refactor: empty state for cuesheet 2025-07-26 10:51:00 +02:00
Carlos Valente 9618bec847 refactor: prevent reflow on visible rows 2025-07-26 10:51:00 +02:00
Carlos Valente 66a4e27fdd refactor: remove unused data 2025-07-26 10:51:00 +02:00
Carlos Valente ef906f43d7 docs: improve feature description 2025-07-26 10:51:00 +02:00
Carlos Valente 93b2a1aece refactor: index test alignment 2025-07-26 10:51:00 +02:00
Carlos Valente 050629d281 fix: stale column data 2025-07-26 10:51:00 +02:00
Carlos Valente abe9012f20 fix: keyboard shortcuts in rundown 2025-07-26 10:51:00 +02:00
84 changed files with 1207 additions and 637 deletions
+14 -59
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-ui",
"version": "4.0.0-alpha.0",
"version": "4.0.0-alpha.1",
"private": true,
"type": "module",
"dependencies": {
+18 -4
View File
@@ -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;
}
+14
View File
@@ -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>
);
}
@@ -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,
};
}
+3 -11
View File
@@ -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&param2=value2',
{
alias: 'test',
target: 'timer',
search: 'param1=value1&param2=value2',
enabled: true,
},
],
[
'cloud URL',
'test',
'https://cloud.getontime.no/timer?param1=value1&param2=value2',
{
alias: 'test',
target: 'timer',
search: 'param1=value1&param2=value2',
enabled: true,
},
],
[
'local URL',
'test',
'http://localhost:4001/timer?param1=value1&param2=value2',
{
alias: 'test',
target: 'timer',
search: 'param1=value1&param2=value2',
enabled: true,
},
],
[
'IP-based URL',
'test',
'http://192.168.0.1:4001/timer?param1=value1&param2=value2',
{
alias: 'test',
target: 'timer',
search: 'param1=value1&param2=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();
});
});
+50 -8
View File
@@ -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
View File
@@ -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>
@@ -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>
);
}
@@ -0,0 +1,9 @@
.expand {
flex: 1;
}
.column {
display: flex;
flex-direction: column;
gap: 1rem;
}
@@ -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>
@@ -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';
}
@@ -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>
);
@@ -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 {
+43 -27
View File
@@ -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>
);
}
+26 -4
View File
@@ -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;
}
}
}
+41 -5
View File
@@ -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();
@@ -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}
/>
);
}
@@ -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 (
@@ -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();
@@ -16,4 +16,8 @@
background-color: $gray-1250;
border-radius: 2px;
}
.indexColumn {
color: $label-gray;
}
}
@@ -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();
@@ -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,
};
@@ -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;
}
@@ -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,
+2 -1
View File
@@ -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 -1
View File
@@ -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",
+1 -1
View File
@@ -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[],
};
+6 -4
View File
@@ -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: {
+6 -1
View File
@@ -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];
+4 -2
View File
@@ -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": {
+10 -7
View File
@@ -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');
+4 -2
View File
@@ -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
View File
@@ -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;
};
+1 -1
View File
@@ -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 {
+1 -1
View File
@@ -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);
};