mirror of
https://github.com/cpvalente/ontime.git
synced 2026-07-26 10:38:55 +00:00
Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1aeaebe1dd | |||
| 2bcd816ff6 | |||
| f6e7f3d57a | |||
| ffd81d2c07 | |||
| 42147d4d5b | |||
| 7850358039 | |||
| 52cae1e22f | |||
| 48f9fb2609 | |||
| ce8a4d230b | |||
| 1553573b94 | |||
| 41a09bf5c3 | |||
| 5dbe7afae8 | |||
| 6633cfc869 | |||
| d127367df9 | |||
| b21ec41ffd | |||
| 66292bc0cf | |||
| b946b245c2 | |||
| c238147b75 | |||
| 5f33c993f6 | |||
| 0e6d6cd416 | |||
| 199dea5df6 | |||
| 5a3ea9f7d0 | |||
| 9ae49a7175 | |||
| d30d9cfd4e | |||
| a0e7b9b665 | |||
| 1dcb7fb08d | |||
| a7e84b9903 | |||
| 3bebbf9ce9 | |||
| ab8da07518 | |||
| c8bae76121 | |||
| 2873698959 | |||
| 741ebef90e | |||
| 0741f46aca | |||
| 5a33ffad92 | |||
| 562f00dc7e | |||
| e50ab0150e | |||
| fe44b33da2 | |||
| 22e392ba9d | |||
| 4997602e9b | |||
| bdb8d4d89a | |||
| b1d5ee12e4 | |||
| a5e733246d | |||
| 1a08b39b8b | |||
| ba1c3235b3 | |||
| 7c1d2f4554 | |||
| 23a44b2f04 | |||
| 2d3c8e7eb2 | |||
| 2f138acad7 |
@@ -12,7 +12,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
# This step is only needed to setup the permissions to update npm as pnpm will setup the correct node version
|
||||
- uses: actions/setup-node@v6
|
||||
@@ -20,13 +20,13 @@ jobs:
|
||||
node-version-file: '.nvmrc'
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
uses: pnpm/action-setup@v6
|
||||
|
||||
- name: Get pnpm store directory
|
||||
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ${{ env.PNPM_STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
|
||||
@@ -12,16 +12,16 @@ jobs:
|
||||
CI: ''
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
uses: pnpm/action-setup@v6
|
||||
|
||||
- name: Get pnpm store directory
|
||||
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ${{ env.PNPM_STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
|
||||
@@ -7,23 +7,23 @@ on:
|
||||
|
||||
jobs:
|
||||
build_macos:
|
||||
runs-on: macOS-latest
|
||||
runs-on: macOS-26
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
uses: pnpm/action-setup@v6
|
||||
|
||||
- name: Get pnpm store directory
|
||||
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ${{ env.PNPM_STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
timeout-minutes: 60
|
||||
|
||||
- name: Upload macOS build artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v6
|
||||
if: always()
|
||||
with:
|
||||
name: ontime-macos-build
|
||||
@@ -54,7 +54,7 @@ jobs:
|
||||
retention-days: 1
|
||||
|
||||
- name: Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
uses: softprops/action-gh-release@v2
|
||||
if: github.ref_type == 'tag'
|
||||
with:
|
||||
files: |
|
||||
@@ -67,14 +67,14 @@ jobs:
|
||||
runs-on: windows-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
uses: pnpm/action-setup@v6
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
@@ -83,7 +83,7 @@ jobs:
|
||||
run: pnpm dist-win
|
||||
|
||||
- name: Upload Windows build artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v6
|
||||
if: always()
|
||||
with:
|
||||
name: ontime-windows-build
|
||||
@@ -93,7 +93,7 @@ jobs:
|
||||
retention-days: 1
|
||||
|
||||
- name: Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
uses: softprops/action-gh-release@v2
|
||||
if: github.ref_type == 'tag'
|
||||
with:
|
||||
files: './apps/electron/dist/ontime-win64.exe'
|
||||
@@ -104,14 +104,14 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
uses: pnpm/action-setup@v6
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
@@ -120,7 +120,7 @@ jobs:
|
||||
run: pnpm dist-linux
|
||||
|
||||
- name: Upload Linux build artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v6
|
||||
if: always()
|
||||
with:
|
||||
name: ontime-linux-build
|
||||
@@ -131,7 +131,7 @@ jobs:
|
||||
retention-days: 1
|
||||
|
||||
- name: Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
uses: softprops/action-gh-release@v2
|
||||
if: github.ref_type == 'tag'
|
||||
with:
|
||||
files: |
|
||||
|
||||
@@ -10,7 +10,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
# This step is only needed to setup the permissions to update npm as pnpm will setup the correct node version
|
||||
- uses: actions/setup-node@v6
|
||||
@@ -18,13 +18,13 @@ jobs:
|
||||
node-version-file: '.nvmrc'
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
uses: pnpm/action-setup@v6
|
||||
|
||||
- name: Get pnpm store directory
|
||||
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ${{ env.PNPM_STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
|
||||
+19
-11
@@ -13,16 +13,20 @@ jobs:
|
||||
CI: ''
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
uses: pnpm/action-setup@v6
|
||||
|
||||
- name: Get pnpm store directory
|
||||
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ${{ env.PNPM_STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
@@ -52,16 +56,20 @@ jobs:
|
||||
timeout-minutes: 20
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
uses: pnpm/action-setup@v6
|
||||
|
||||
- name: Get pnpm store directory
|
||||
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ${{ env.PNPM_STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
@@ -74,30 +82,30 @@ jobs:
|
||||
- name: Get installed Playwright version
|
||||
run: echo "PLAYWRIGHT_VERSION=$(pnpm ls @playwright/test --parseable | cut -s -d '@' -f3 | cut -d '/' -f1)" >> $GITHUB_ENV
|
||||
- name: Cache playwright binaries
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
~/.cache/ms-playwright
|
||||
key: ${{ runner.os }}-playwright-${{ env.PLAYWRIGHT_VERSION }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-playwright-
|
||||
- run: npx playwright install --with-deps
|
||||
- run: pnpm playwright install --with-deps
|
||||
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
||||
|
||||
- name: Install Playwright Browsers
|
||||
run: npx playwright install --with-deps
|
||||
run: pnpm playwright install --with-deps
|
||||
|
||||
- name: Run Playwright tests
|
||||
run: pnpm e2e
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@v6
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-report
|
||||
path: playwright-report/
|
||||
retention-days: 30
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: automated-screenshots
|
||||
path: automated-screenshots/
|
||||
|
||||
@@ -54,3 +54,6 @@ ontime-data/
|
||||
|
||||
# temporary write files
|
||||
**.tmp
|
||||
|
||||
# Claude Code metadata
|
||||
.claude/
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
ARG NODE_VERSION=22.15.1
|
||||
ARG NODE_VERSION=22.22.2
|
||||
FROM node:${NODE_VERSION}-alpine
|
||||
|
||||
# Set environment variables
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getontime/cli",
|
||||
"version": "4.7.0",
|
||||
"version": "4.10.0",
|
||||
"author": "Carlos Valente",
|
||||
"description": "Time keeping for live events",
|
||||
"repository": "https://github.com/cpvalente/ontime",
|
||||
|
||||
@@ -4,9 +4,10 @@
|
||||
<meta charset="utf-8" />
|
||||
<base href="/" />
|
||||
<link rel="icon" href="favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#101010" />
|
||||
<meta name="ontime" content="ontime - time keeping for live events" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<link rel="apple-touch-icon" href="ontime-logo.png" />
|
||||
<link rel="icon" type="image/png" href="ontime-logo.png" />
|
||||
<link rel="manifest" href="site.webmanifest" />
|
||||
@@ -17,6 +18,13 @@
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<script>
|
||||
if (/\/timer\/?$/.test(window.location.pathname) && typeof Promise.any !== 'function') {
|
||||
var b = document.querySelector('base');
|
||||
var base = b ? (b.getAttribute('href') || '').replace(/\/$/, '') : '';
|
||||
window.location.replace(base + '/timer-legacy' + window.location.search);
|
||||
}
|
||||
</script>
|
||||
<script type="module" src="/src/index.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+20
-19
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"name": "ontime-ui",
|
||||
"version": "4.7.0",
|
||||
"version": "4.10.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@base-ui/react": "1.3.0",
|
||||
"@base-ui/react": "1.6.0",
|
||||
"@codemirror/commands": "^6.0.0",
|
||||
"@codemirror/lang-css": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
@@ -12,28 +12,27 @@
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@fontsource/open-sans": "^5.2.6",
|
||||
"@fontsource/open-sans": "^5.2.7",
|
||||
"@mantine/hooks": "^8.3.7",
|
||||
"@sentry/react": "^10.2.0",
|
||||
"@sentry/react": "^10.59.0",
|
||||
"@table-nav/react": "^0.0.7",
|
||||
"@tanstack/react-query": "^5.85.9",
|
||||
"@tanstack/react-query-devtools": "^5.85.9",
|
||||
"@tanstack/react-query": "^5.101.0",
|
||||
"@tanstack/react-query-devtools": "^5.101.0",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@uiw/codemirror-theme-vscode": "^4.25.9",
|
||||
"@uiw/codemirror-theme-vscode": "^4.25.10",
|
||||
"autosize": "^6.0.1",
|
||||
"axios": "^1.12.2",
|
||||
"axios": "^1.18.0",
|
||||
"csv-stringify": "^6.6.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.2.3",
|
||||
"react-colorful": "^5.6.1",
|
||||
"react-dom": "^19.2.3",
|
||||
"react": "^19.2.7",
|
||||
"react-colorful": "^5.7.0",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-fast-compare": "^3.2.2",
|
||||
"react-hook-form": "^7.72.0",
|
||||
"react-icons": "5.5.0",
|
||||
"react-router": "^7.11.0",
|
||||
"react-virtuoso": "^4.17.0",
|
||||
"web-vitals": "^5.1.0",
|
||||
"zustand": "^5.0.9"
|
||||
"react-hook-form": "^7.80.0",
|
||||
"react-icons": "5.6.0",
|
||||
"react-router": "^8.0.1",
|
||||
"react-virtuoso": "^4.18.7",
|
||||
"zustand": "^5.0.14"
|
||||
},
|
||||
"scripts": {
|
||||
"addversion": "node -p \"'export const ONTIME_VERSION = ' + JSON.stringify(require('../../package.json').version) + ';'\" > src/ONTIME_VERSION.js",
|
||||
@@ -51,7 +50,8 @@
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
"not op_mini all",
|
||||
"safari >= 14.1"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
@@ -64,8 +64,10 @@
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^19.1.12",
|
||||
"@types/react-dom": "^19.1.9",
|
||||
"@vitejs/plugin-legacy": "^8.0.1",
|
||||
"@vitejs/plugin-react": "5.2.0",
|
||||
"babel-plugin-react-compiler": "1.0.0",
|
||||
"happy-dom": "^20.9.0",
|
||||
"ontime-types": "workspace:*",
|
||||
"ontime-utils": "workspace:*",
|
||||
"sass": "^1.57.1",
|
||||
@@ -73,7 +75,6 @@
|
||||
"vite": "8.0.1",
|
||||
"vite-plugin-compression2": "2.5.1",
|
||||
"vite-plugin-svgr": "4.5.0",
|
||||
"vite-tsconfig-paths": "6.1.1",
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,5 +15,5 @@
|
||||
"start_url": "./",
|
||||
"display": "",
|
||||
"theme_color": "#121212",
|
||||
"background_color": "#ffffff"
|
||||
"background_color": "#101010"
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "",
|
||||
"short_name": "",
|
||||
"icons": [{ "src": "ontime-logo.png", "sizes": "295x295", "type": "image/png" }],
|
||||
"theme_color": "#2B5ABC",
|
||||
"theme_color": "#121212",
|
||||
"background_color": "#101010",
|
||||
"display": "standalone"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import axios, { AxiosResponse } from 'axios';
|
||||
import { EntryId, OntimeEntry, OntimeEvent, ProjectRundownsList, Rundown, TransientEventPayload } from 'ontime-types';
|
||||
import {
|
||||
EntryId,
|
||||
OntimeEntry,
|
||||
OntimeEvent,
|
||||
ProjectRundownsList,
|
||||
RenumberCues,
|
||||
Rundown,
|
||||
TransientEventPayload,
|
||||
} from 'ontime-types';
|
||||
|
||||
import { apiEntryUrl } from './constants';
|
||||
import type { RequestOptions } from './requestOptions';
|
||||
@@ -26,19 +34,17 @@ export async function fetchCurrentRundown(options?: RequestOptions): Promise<Run
|
||||
throw new Error('Invalid rundown payload');
|
||||
}
|
||||
return res.data;
|
||||
}
|
||||
|
||||
function isValidRundown(x: any): x is Rundown {
|
||||
return (
|
||||
x &&
|
||||
typeof x === 'object' &&
|
||||
typeof x.id === 'string' &&
|
||||
Array.isArray(x.order) &&
|
||||
Array.isArray(x.flatOrder) &&
|
||||
x.entries &&
|
||||
typeof x.entries === 'object' &&
|
||||
typeof x.revision === 'number'
|
||||
);
|
||||
/**
|
||||
* HTTP request to fetch all entries in the given rundown
|
||||
*/
|
||||
export async function fetchRundown(rundownId: RundownId, options?: RequestOptions): Promise<Rundown> {
|
||||
const res = await axios.get(`${rundownPath}/${rundownId}`, { signal: options?.signal });
|
||||
if (!isValidRundown(res.data)) {
|
||||
throw new Error('Invalid rundown payload');
|
||||
}
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -111,6 +117,13 @@ export async function putBatchEditEvents(rundownId: RundownId, data: BatchEditEn
|
||||
return axios.put(`${rundownPath}/${rundownId}/batch`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to renumber cues for multiple events
|
||||
*/
|
||||
export function patchRenumberCues(rundownId: RundownId, data: RenumberCues): Promise<AxiosResponse<Rundown>> {
|
||||
return axios.patch(`${rundownPath}/${rundownId}/renumber`, data);
|
||||
}
|
||||
|
||||
export type ReorderEntry = {
|
||||
entryId: EntryId;
|
||||
destinationId: EntryId;
|
||||
@@ -182,3 +195,16 @@ export async function requestDeleteAll(rundownId: RundownId): Promise<AxiosRespo
|
||||
}
|
||||
|
||||
// #endregion operations on rundown entries =======================
|
||||
|
||||
function isValidRundown(x: any): x is Rundown {
|
||||
return (
|
||||
x &&
|
||||
typeof x === 'object' &&
|
||||
typeof x.id === 'string' &&
|
||||
Array.isArray(x.order) &&
|
||||
Array.isArray(x.flatOrder) &&
|
||||
x.entries &&
|
||||
typeof x.entries === 'object' &&
|
||||
typeof x.revision === 'number'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
// skipcq: JS-C1003 - sentry does not expose itself as an ES Module.
|
||||
import * as Sentry from '@sentry/react';
|
||||
/* eslint-disable react/destructuring-assignment */
|
||||
import React from 'react';
|
||||
|
||||
import { hasConnected, reconnectAttempts } from '../../../common/utils/socket';
|
||||
import { runtimeStore } from '../../stores/runtime';
|
||||
import { getConnectionState, getReconnectAttempts } from '../../utils/socket';
|
||||
|
||||
import style from './ErrorBoundary.module.scss';
|
||||
|
||||
@@ -37,7 +36,7 @@ class ErrorBoundary extends React.Component {
|
||||
scope.setExtras({
|
||||
error,
|
||||
store: appState,
|
||||
hasSocket: { hasConnected, reconnectAttempts },
|
||||
hasSocket: { hasConnected: getConnectionState(), reconnectAttempts: getReconnectAttempts() },
|
||||
});
|
||||
const eventId = Sentry.captureException(error);
|
||||
this.setState({ eventId, info });
|
||||
|
||||
@@ -3,8 +3,8 @@ import { ChangeEvent, KeyboardEvent, RefObject, useCallback, useEffect, useMemo,
|
||||
|
||||
interface UseReactiveTextInputReturn {
|
||||
value: string;
|
||||
onChange: (event: ChangeEvent) => void;
|
||||
onBlur: (event: ChangeEvent) => void;
|
||||
onChange: (event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => void;
|
||||
onBlur: (event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => void;
|
||||
onKeyDown: (event: KeyboardEvent<HTMLElement>) => void;
|
||||
}
|
||||
|
||||
@@ -15,7 +15,9 @@ export default function useReactiveTextInput(
|
||||
options?: {
|
||||
submitOnEnter?: boolean;
|
||||
submitOnCtrlEnter?: boolean;
|
||||
submitOnTab?: boolean;
|
||||
onCancelUpdate?: () => void;
|
||||
onTabCancel?: () => void;
|
||||
allowSubmitSameValue?: boolean;
|
||||
allowKeyboardNavigation?: boolean;
|
||||
},
|
||||
@@ -23,6 +25,10 @@ export default function useReactiveTextInput(
|
||||
const [text, setText] = useState<string>(initialText);
|
||||
// track whether we are submitting via a submit key (eg enter) and avoid submitting again on blur
|
||||
const isKeyboardSubmitting = useRef(false);
|
||||
// track escape to prevent the subsequent blur from submitting
|
||||
const isEscaping = useRef(false);
|
||||
// track tab-out to prevent blur from submitting (used by timer cells)
|
||||
const isTabbing = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof initialText === 'undefined') {
|
||||
@@ -78,6 +84,7 @@ export default function useReactiveTextInput(
|
||||
* @param {string} valueToSubmit
|
||||
*/
|
||||
const handleEscape = useCallback(() => {
|
||||
isEscaping.current = true;
|
||||
// No need to update if it hasn't changed
|
||||
setText(initialText);
|
||||
// force the text to be the initial value
|
||||
@@ -141,16 +148,30 @@ export default function useReactiveTextInput(
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
// for cells that opt out of tab-submit, track tab-out without preventing navigation
|
||||
if (event.key === 'Tab' && options?.submitOnTab === false) {
|
||||
isTabbing.current = true;
|
||||
}
|
||||
|
||||
hotKeyHandler(event);
|
||||
};
|
||||
}, [handleEscape, handleSubmit, options?.submitOnCtrlEnter, options?.submitOnEnter, text]);
|
||||
}, [handleEscape, handleSubmit, options?.submitOnCtrlEnter, options?.submitOnEnter, options?.submitOnTab, text]);
|
||||
|
||||
return {
|
||||
value: text,
|
||||
onChange: (event: ChangeEvent) => handleChange((event.target as HTMLInputElement).value),
|
||||
onBlur: (event: ChangeEvent) => {
|
||||
onChange: (event) => handleChange(event.target.value),
|
||||
onBlur: (event) => {
|
||||
if (isTabbing.current) {
|
||||
isTabbing.current = false;
|
||||
(options?.onTabCancel ?? options?.onCancelUpdate)?.();
|
||||
return;
|
||||
}
|
||||
if (isEscaping.current) {
|
||||
isEscaping.current = false;
|
||||
return;
|
||||
}
|
||||
if (!isKeyboardSubmitting.current) {
|
||||
handleSubmit((event.target as HTMLInputElement).value);
|
||||
handleSubmit(event.target.value);
|
||||
}
|
||||
},
|
||||
onKeyDown: keyHandler,
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useLocation } from 'react-router';
|
||||
import { isLocalhost, supportsFullscreen } from '../../../externals';
|
||||
import { canUseWakeLock, useKeepAwakeOptions } from '../../../features/keep-awake/useWakeLock';
|
||||
import { navigatorConstants } from '../../../viewerConfig';
|
||||
import useUrlPresets from '../../hooks-query/useUrlPresets';
|
||||
import { useIsSmallScreen } from '../../hooks/useIsSmallScreen';
|
||||
import { useClientStore } from '../../stores/clientStore';
|
||||
import { useViewOptionsStore } from '../../stores/viewOptions';
|
||||
@@ -105,6 +106,8 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
|
||||
{route.label}
|
||||
</ClientLink>
|
||||
))}
|
||||
|
||||
<PresetNavigation isSmallScreen={isSmallScreen} onClose={onClose} />
|
||||
</div>
|
||||
|
||||
{isLocalhost && (
|
||||
@@ -117,3 +120,27 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
|
||||
</Dialog.Root>
|
||||
);
|
||||
}
|
||||
|
||||
function PresetNavigation({ isSmallScreen, onClose }: { isSmallScreen: boolean; onClose: () => void }) {
|
||||
const location = useLocation();
|
||||
const { data: urlPresets } = useUrlPresets();
|
||||
const navPresets = urlPresets.filter((preset) => preset.enabled && preset.displayInNav);
|
||||
|
||||
if (navPresets.length === 0) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<hr className={style.separator} />
|
||||
{navPresets.map((preset) => (
|
||||
<ClientLink
|
||||
key={preset.alias}
|
||||
to={`preset/${preset.alias}`}
|
||||
current={location.pathname === `/preset/${preset.alias}`}
|
||||
postAction={isSmallScreen ? onClose : undefined}
|
||||
>
|
||||
{preset.alias}
|
||||
</ClientLink>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,9 +8,12 @@
|
||||
padding-top: 10vh;
|
||||
}
|
||||
|
||||
.empty {
|
||||
width: 100%;
|
||||
opacity: 0.8;
|
||||
.inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
margin-top: 1em;
|
||||
}
|
||||
|
||||
.text {
|
||||
|
||||
@@ -1,18 +1,33 @@
|
||||
import EmptyImage from '../../../assets/images/empty.svg?react';
|
||||
import { SupportedEntry } from 'ontime-types';
|
||||
import { IoAdd } from 'react-icons/io5';
|
||||
|
||||
import { useTranslation } from '../../../translation/TranslationProvider';
|
||||
import Button from '../buttons/Button';
|
||||
import Empty from './Empty';
|
||||
|
||||
import style from './EmptyTableBody.module.scss';
|
||||
|
||||
interface EmptyTableBodyProps {
|
||||
text: string;
|
||||
handleAddNew?: (type: SupportedEntry) => void;
|
||||
}
|
||||
|
||||
export default function EmptyTableBody({ text }: EmptyTableBodyProps) {
|
||||
export default function EmptyTableBody({ handleAddNew }: EmptyTableBodyProps) {
|
||||
const { getLocalizedString } = useTranslation();
|
||||
const text = getLocalizedString('common.no_data');
|
||||
return (
|
||||
<tbody className={style.emptyContainer}>
|
||||
<tr>
|
||||
<td colSpan={99} className={style.emptyCell}>
|
||||
<EmptyImage className={style.empty} />
|
||||
{text && <span className={style.text}>{text}</span>}
|
||||
<Empty injectedStyles={{ marginTop: '5vh' }} />
|
||||
<span className={style.text}>{text}</span>
|
||||
{handleAddNew && (
|
||||
<div className={style.inline}>
|
||||
<Button onClick={() => handleAddNew(SupportedEntry.Event)} variant='primary' size='large'>
|
||||
<IoAdd />
|
||||
Create Event
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
||||
@@ -64,8 +64,8 @@ function SectionContents({ options, collapsed }: SectionContentsProps) {
|
||||
function HiddenContents({ options }: { options: ParamField[] }) {
|
||||
return (
|
||||
<>
|
||||
{options.map((option, index) => {
|
||||
return <ParamInput key={option.title + index} paramField={option} />;
|
||||
{options.map((option) => {
|
||||
return <ParamInput key={option.id} paramField={option} />;
|
||||
})}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -26,3 +26,14 @@ export const showLeadingZeros: ParamField = {
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
};
|
||||
|
||||
export type TimeOptions = {
|
||||
timeformat: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper to get value of 'timeformat' from either source, prioritizing defaultValues
|
||||
*/
|
||||
export function getTimeOptionsFromParams(searchParams: URLSearchParams, defaultValues?: URLSearchParams) {
|
||||
return defaultValues?.get('timeformat') ?? searchParams.get('timeformat');
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { langEn } from 'ontime-types';
|
||||
import { MILLIS_PER_HOUR } from 'ontime-utils';
|
||||
|
||||
import { getUserTranslation } from '../../common/api/assets';
|
||||
import { TRANSLATION } from '../../common/api/constants';
|
||||
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
||||
|
||||
export function useCustomTranslation() {
|
||||
const { data, status, refetch } = useQuery({
|
||||
queryKey: TRANSLATION,
|
||||
queryFn: ({ signal }) => getUserTranslation({ signal }),
|
||||
placeholderData: (previousData, _previousQuery) => previousData,
|
||||
refetchInterval: queryRefetchIntervalSlow,
|
||||
staleTime: MILLIS_PER_HOUR,
|
||||
});
|
||||
return { data: data ?? langEn, status, refetch };
|
||||
}
|
||||
|
||||
@@ -1,24 +1,18 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { ProjectFile, ProjectFileList, ProjectFileListResponse } from 'ontime-types';
|
||||
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { ProjectFile, ProjectFileList } from 'ontime-types';
|
||||
import { MILLIS_PER_HOUR } from 'ontime-utils';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
||||
import { PROJECT_LIST } from '../api/constants';
|
||||
import { getProjects } from '../api/db';
|
||||
|
||||
const placeholderProjectList: ProjectFileListResponse = {
|
||||
files: [],
|
||||
lastLoadedProject: '',
|
||||
};
|
||||
|
||||
function useProjectList() {
|
||||
const { data, status, refetch } = useQuery({
|
||||
export function useProjectList() {
|
||||
const { data, status, refetch } = useSuspenseQuery({
|
||||
queryKey: PROJECT_LIST,
|
||||
queryFn: ({ signal }) => getProjects({ signal }),
|
||||
placeholderData: (previousData, _previousQuery) => previousData,
|
||||
refetchInterval: queryRefetchIntervalSlow,
|
||||
staleTime: MILLIS_PER_HOUR,
|
||||
});
|
||||
return { data: data ?? placeholderProjectList, status, refetch };
|
||||
return { data, status, refetch };
|
||||
}
|
||||
|
||||
export type ProjectSortMode = 'alphabetical-asc' | 'alphabetical-desc' | 'modified-asc' | 'modified-desc';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useMutation, useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { ProjectRundownsList } from 'ontime-types';
|
||||
import { MILLIS_PER_HOUR } from 'ontime-utils';
|
||||
|
||||
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
||||
import { PROJECT_RUNDOWNS } from '../api/constants';
|
||||
import {
|
||||
createRundown,
|
||||
@@ -11,23 +11,21 @@ import {
|
||||
loadRundown,
|
||||
renameRundown,
|
||||
} from '../api/rundown';
|
||||
import { ontimeQueryClient } from '../queryClient';
|
||||
|
||||
/**
|
||||
* Project rundowns
|
||||
*/
|
||||
export function useProjectRundowns() {
|
||||
const { data, status, isError, refetch, isFetching } = useQuery<ProjectRundownsList>({
|
||||
const { data, status, isError, refetch, isFetching } = useSuspenseQuery<ProjectRundownsList>({
|
||||
queryKey: PROJECT_RUNDOWNS,
|
||||
queryFn: ({ signal }) => fetchProjectRundownList({ signal }),
|
||||
placeholderData: (previousData, _previousQuery) => previousData,
|
||||
refetchInterval: queryRefetchIntervalSlow,
|
||||
staleTime: MILLIS_PER_HOUR,
|
||||
});
|
||||
return { data: data ?? { loaded: '', rundowns: [] }, status, isError, refetch, isFetching };
|
||||
return { data, status, isError, refetch, isFetching };
|
||||
}
|
||||
|
||||
export function useMutateProjectRundowns() {
|
||||
const ontimeQueryClient = useQueryClient();
|
||||
|
||||
const { mutateAsync: create } = useMutation({
|
||||
mutationFn: createRundown,
|
||||
onMutate: () => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useEffect, useMemo } from 'react';
|
||||
|
||||
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
||||
import { CURRENT_RUNDOWN_QUERY_KEY, getRundownQueryKey } from '../api/constants';
|
||||
import { fetchCurrentRundown } from '../api/rundown';
|
||||
import { fetchCurrentRundown, fetchRundown } from '../api/rundown';
|
||||
import { useSelectedEventId } from '../hooks/useSocket';
|
||||
import { ExtendedEntry, getFlatRundownMetadata, getRundownMetadata } from '../utils/rundownMetadata';
|
||||
import { useProjectRundowns } from './useProjectRundowns';
|
||||
@@ -20,7 +20,11 @@ const cachedRundownPlaceholder: Rundown = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalised rundown data
|
||||
* Normalised rundown data for the currently loaded rundown.
|
||||
*
|
||||
* Bootstraps via the `/current` alias so the first paint is a single round-trip,
|
||||
* independent of the project rundown list. Once the loaded id is known, the
|
||||
* query key swaps to the id-keyed cache that is shared with `useRundownById`.
|
||||
*/
|
||||
export default function useRundown() {
|
||||
const queryClient = useQueryClient();
|
||||
@@ -34,12 +38,13 @@ export default function useRundown() {
|
||||
refetchInterval: queryRefetchIntervalSlow,
|
||||
});
|
||||
|
||||
// Seed the ID-based cache when fetching via the 'current' alias (bootstrap)
|
||||
// Seed the id-keyed cache when fetching via the bootstrap alias
|
||||
useEffect(() => {
|
||||
if (!data || loadedRundownId) return;
|
||||
queryClient.setQueryData(getRundownQueryKey(data.id), data);
|
||||
}, [data, loadedRundownId, queryClient]);
|
||||
|
||||
// Once we have the ID, drop the temporary current cache
|
||||
useEffect(() => {
|
||||
if (!loadedRundownId) return;
|
||||
queryClient.removeQueries({ queryKey: CURRENT_RUNDOWN_QUERY_KEY, exact: true });
|
||||
@@ -114,3 +119,21 @@ export function useRundownAuxData() {
|
||||
}, [data]);
|
||||
return { data: filteredData, status };
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides access to a specific rundown by ID.
|
||||
* When rundownId is null/undefined the query is disabled and returns the placeholder.
|
||||
*/
|
||||
export function useRundownById(rundownId: string | null | undefined) {
|
||||
const enabled = Boolean(rundownId);
|
||||
|
||||
const { data, status, isError, refetch, isFetching } = useQuery<Rundown>({
|
||||
queryKey: getRundownQueryKey(rundownId ?? ''),
|
||||
queryFn: ({ signal }) => fetchRundown(rundownId!, { signal }),
|
||||
enabled,
|
||||
placeholderData: (previousData, _previousQuery) => previousData,
|
||||
refetchInterval: queryRefetchIntervalSlow,
|
||||
});
|
||||
|
||||
return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { EntryId, Rundown } from 'ontime-types';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useSelectedEventId } from '../hooks/useSocket';
|
||||
import { getFlatRundownMetadata, type ExtendedEntry } from '../utils/rundownMetadata';
|
||||
import { useProjectRundowns } from './useProjectRundowns';
|
||||
import { useRundownById } from './useRundown';
|
||||
|
||||
export type RundownSource = {
|
||||
rundownId: string | null;
|
||||
rundown: Rundown;
|
||||
flatRundown: ExtendedEntry[];
|
||||
status: string;
|
||||
selectedEventId: EntryId | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Explicitly scoped rundown data for views that may operate on a non-loaded rundown.
|
||||
*/
|
||||
export function useScopedRundown(rundownId: string | null): RundownSource {
|
||||
const { data: projectRundowns } = useProjectRundowns();
|
||||
return useRundownSource(rundownId, projectRundowns.loaded || null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loaded-rundown source for views that must follow the active runtime rundown.
|
||||
*/
|
||||
export function useLoadedRundownSource(): RundownSource {
|
||||
const { data: projectRundowns } = useProjectRundowns();
|
||||
const loadedRundownId = projectRundowns.loaded || null;
|
||||
return useRundownSource(loadedRundownId, loadedRundownId);
|
||||
}
|
||||
|
||||
function useRundownSource(rundownId: string | null, loadedRundownId: string | null): RundownSource {
|
||||
const isLoadedTarget = rundownId !== null && rundownId === loadedRundownId;
|
||||
const runtimeSelectedEventId = useSelectedEventId();
|
||||
const effectiveSelectedEventId = isLoadedTarget ? runtimeSelectedEventId : null;
|
||||
const { data: rundown, status } = useRundownById(rundownId);
|
||||
const flatRundown = useMemo(
|
||||
() => getFlatRundownMetadata(rundown, effectiveSelectedEventId),
|
||||
[effectiveSelectedEventId, rundown],
|
||||
);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
rundownId,
|
||||
rundown,
|
||||
flatRundown,
|
||||
status,
|
||||
selectedEventId: effectiveSelectedEventId,
|
||||
}),
|
||||
[effectiveSelectedEventId, flatRundown, rundown, rundownId, status],
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
TimeStrategy,
|
||||
isOntimeEvent,
|
||||
isOntimeGroup,
|
||||
isOntimeMilestone,
|
||||
} from 'ontime-types';
|
||||
import {
|
||||
MILLIS_PER_SECOND,
|
||||
@@ -41,6 +42,7 @@ import {
|
||||
patchReorderEntry,
|
||||
postAddEntry,
|
||||
postCloneEntry,
|
||||
patchRenumberCues,
|
||||
putBatchEditEvents,
|
||||
putEditEntry,
|
||||
requestApplyDelay,
|
||||
@@ -62,9 +64,16 @@ export type EventOptions = Partial<{
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Gather utilities for actions on entries
|
||||
* Gather utilities for actions on entries in the loaded rundown.
|
||||
*/
|
||||
export const useEntryActions = () => {
|
||||
export const useEntryActions = () => useEntryActionsForRundown(undefined);
|
||||
|
||||
/**
|
||||
* Gather utilities for actions on entries in an explicitly selected rundown.
|
||||
*/
|
||||
export const useScopedEntryActions = (rundownId: string | null) => useEntryActionsForRundown(rundownId ?? '');
|
||||
|
||||
function useEntryActionsForRundown(scopedRundownId: string | undefined) {
|
||||
const queryClient = useQueryClient();
|
||||
const {
|
||||
linkPrevious,
|
||||
@@ -74,15 +83,16 @@ export const useEntryActions = () => {
|
||||
defaultDangerTime,
|
||||
defaultTimerType,
|
||||
defaultEndAction,
|
||||
inheritGroupColour,
|
||||
} = useEditorSettings();
|
||||
|
||||
const resolveCurrentRundownQueryKey = useCallback(() => {
|
||||
const loadedRundownId = queryClient.getQueryData<ProjectRundownsList>(PROJECT_RUNDOWNS)?.loaded;
|
||||
if (loadedRundownId) {
|
||||
return getRundownQueryKey(loadedRundownId);
|
||||
if (scopedRundownId !== undefined) {
|
||||
return getRundownQueryKey(scopedRundownId);
|
||||
}
|
||||
return CURRENT_RUNDOWN_QUERY_KEY;
|
||||
}, [queryClient]);
|
||||
const loadedRundownId = queryClient.getQueryData<ProjectRundownsList>(PROJECT_RUNDOWNS)?.loaded;
|
||||
return loadedRundownId ? getRundownQueryKey(loadedRundownId) : CURRENT_RUNDOWN_QUERY_KEY;
|
||||
}, [queryClient, scopedRundownId]);
|
||||
|
||||
/**
|
||||
* Returns the currently loaded rundown
|
||||
@@ -231,6 +241,14 @@ export const useEntryActions = () => {
|
||||
}
|
||||
}
|
||||
|
||||
if (inheritGroupColour && (isOntimeEvent(newEntry) || isOntimeMilestone(newEntry)) && !newEntry.colour) {
|
||||
const parentId = resolveInsertParent(rundownData, newEntry);
|
||||
const maybeParent = parentId ? rundownData.entries[parentId] : null;
|
||||
if (maybeParent && isOntimeGroup(maybeParent)) {
|
||||
newEntry.colour = maybeParent.colour;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await addEntryMutation([rundownId, newEntry]);
|
||||
} catch (error) {
|
||||
@@ -246,6 +264,7 @@ export const useEntryActions = () => {
|
||||
defaultTimerType,
|
||||
defaultEndAction,
|
||||
defaultTimeStrategy,
|
||||
inheritGroupColour,
|
||||
addEntryMutation,
|
||||
],
|
||||
);
|
||||
@@ -523,6 +542,39 @@ export const useEntryActions = () => {
|
||||
[batchUpdateEventsMutation, getCurrentRundownData],
|
||||
);
|
||||
|
||||
const { mutateAsync: renumberCuesMutation } = useMutation({
|
||||
mutationFn: ([rundownId, body]: Parameters<typeof patchRenumberCues>) => patchRenumberCues(rundownId, body),
|
||||
onMutate: async () => {
|
||||
const queryKey = resolveCurrentRundownQueryKey();
|
||||
await queryClient.cancelQueries({ queryKey });
|
||||
const previousRundown = queryClient.getQueryData<Rundown>(queryKey);
|
||||
return { previousRundown, queryKey };
|
||||
},
|
||||
onSuccess: (response, _variables, context) => {
|
||||
if (!response.data || !context?.queryKey) return;
|
||||
const updatedRundown = response.data;
|
||||
queryClient.setQueryData<Rundown>(context.queryKey, updatedRundown);
|
||||
},
|
||||
onError: (_error, _vars, context) => {
|
||||
if (context?.previousRundown) queryClient.setQueryData<Rundown>(context.queryKey, context.previousRundown);
|
||||
},
|
||||
});
|
||||
|
||||
const renumberCues = useCallback(
|
||||
async (eventIds: EntryId[], prefix: string, start: string, increment: string) => {
|
||||
const rundown = getCurrentRundownData();
|
||||
const rundownId = rundown?.id;
|
||||
if (!rundownId) throw new Error('Rundown not initialized');
|
||||
try {
|
||||
await renumberCuesMutation([rundownId, { ids: eventIds, prefix, start, increment }]);
|
||||
} catch (error) {
|
||||
logAxiosError('Error renumbering cues', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[getCurrentRundownData, renumberCuesMutation],
|
||||
);
|
||||
|
||||
/**
|
||||
* Calls mutation to delete an entry
|
||||
* @private
|
||||
@@ -947,6 +999,7 @@ export const useEntryActions = () => {
|
||||
groupEntries,
|
||||
move,
|
||||
reorderEntry,
|
||||
renumberCues,
|
||||
swapEvents,
|
||||
updateEntry,
|
||||
updateTimer,
|
||||
@@ -963,12 +1016,13 @@ export const useEntryActions = () => {
|
||||
groupEntries,
|
||||
move,
|
||||
reorderEntry,
|
||||
renumberCues,
|
||||
swapEvents,
|
||||
updateEntry,
|
||||
updateTimer,
|
||||
],
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility to optimistically delete entries from client cache
|
||||
|
||||
@@ -12,8 +12,10 @@ type EditorSettingsStore = {
|
||||
defaultDangerTime: string;
|
||||
defaultTimerType: TimerType;
|
||||
defaultEndAction: EndAction;
|
||||
inheritGroupColour: boolean;
|
||||
setDefaultDuration: (defaultDuration: string) => void;
|
||||
setLinkPrevious: (linkPrevious: boolean) => void;
|
||||
setInheritGroupColour: (inheritGroupColour: boolean) => void;
|
||||
setTimeStrategy: (timeStrategy: TimeStrategy) => void;
|
||||
setWarnTime: (warnTime: string) => void;
|
||||
setDangerTime: (dangerTime: string) => void;
|
||||
@@ -29,6 +31,7 @@ export const editorSettingsDefaults = {
|
||||
dangerTime: '00:01:00', // 60000 same as backend
|
||||
timerType: TimerType.CountDown,
|
||||
endAction: EndAction.None,
|
||||
inheritGroupColour: false,
|
||||
};
|
||||
|
||||
enum EditorSettingsKeys {
|
||||
@@ -39,6 +42,7 @@ enum EditorSettingsKeys {
|
||||
DefaultDangerTime = 'ontime-default-danger-time',
|
||||
DefaultTimerType = 'ontime-default-timer-type',
|
||||
DefaultEndAction = 'ontime-default-end-action',
|
||||
InheritGroupColour = 'ontime-inherit-group-colour',
|
||||
}
|
||||
|
||||
export const useEditorSettings = create<EditorSettingsStore>((set) => {
|
||||
@@ -59,6 +63,10 @@ export const useEditorSettings = create<EditorSettingsStore>((set) => {
|
||||
localStorage.getItem(EditorSettingsKeys.DefaultEndAction),
|
||||
editorSettingsDefaults.endAction,
|
||||
),
|
||||
inheritGroupColour: booleanFromLocalStorage(
|
||||
EditorSettingsKeys.InheritGroupColour,
|
||||
editorSettingsDefaults.inheritGroupColour,
|
||||
),
|
||||
|
||||
setDefaultDuration: (defaultDuration) =>
|
||||
set(() => {
|
||||
@@ -97,5 +105,10 @@ export const useEditorSettings = create<EditorSettingsStore>((set) => {
|
||||
localStorage.setItem(EditorSettingsKeys.DefaultEndAction, String(defaultEndAction));
|
||||
return { defaultEndAction };
|
||||
}),
|
||||
setInheritGroupColour: (inheritGroupColour) =>
|
||||
set(() => {
|
||||
localStorage.setItem(EditorSettingsKeys.InheritGroupColour, String(inheritGroupColour));
|
||||
return { inheritGroupColour };
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -37,6 +37,16 @@ describe('getRouteFromPreset()', () => {
|
||||
alias: 'demopage',
|
||||
target: OntimeView.Timer,
|
||||
search: 'user=guest',
|
||||
displayInNav: false,
|
||||
options: {},
|
||||
},
|
||||
];
|
||||
const disabledPresets: URLPreset[] = [
|
||||
{
|
||||
enabled: false,
|
||||
alias: 'demopage',
|
||||
target: OntimeView.Timer,
|
||||
search: 'user=guest',
|
||||
options: {},
|
||||
},
|
||||
];
|
||||
@@ -47,6 +57,21 @@ describe('getRouteFromPreset()', () => {
|
||||
expect(getRouteFromPreset(location, presets)).toStrictEqual('timer?user=guest&alias=demopage');
|
||||
});
|
||||
|
||||
it('checks if the current location matches an enabled preset target', () => {
|
||||
const location = resolvePath('/timer');
|
||||
expect(getRouteFromPreset(location, presets)).toStrictEqual('timer?user=guest&alias=demopage');
|
||||
});
|
||||
|
||||
it('does not redirect disabled presets', () => {
|
||||
const location = resolvePath('/demopage');
|
||||
expect(getRouteFromPreset(location, disabledPresets)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when already on a preset path', () => {
|
||||
const location = resolvePath('/preset/demopage');
|
||||
expect(getRouteFromPreset(location, presets)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null if the current location is the exact match of an unwrapped alias', () => {
|
||||
// we make the current location be the alias
|
||||
const location = resolvePath('/timer?user=guest&alias=demopage');
|
||||
@@ -81,6 +106,11 @@ describe('getRouteFromPreset()', () => {
|
||||
const location = resolvePath('/demopage?n=1&token=123');
|
||||
expect(getRouteFromPreset(location, presets)).toBe('timer?user=guest&alias=demopage&n=1&token=123');
|
||||
});
|
||||
|
||||
it('redirects stale unwrapped params back to the saved preset params while preserving feature params', () => {
|
||||
const location = resolvePath('/timer?user=admin&alias=demopage&n=1&token=123');
|
||||
expect(getRouteFromPreset(location, presets)).toBe('timer?user=guest&alias=demopage&n=1&token=123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('cuesheet presets', () => {
|
||||
@@ -90,6 +120,7 @@ describe('getRouteFromPreset()', () => {
|
||||
alias: 'cuesheet-4685d6',
|
||||
target: OntimeView.Cuesheet,
|
||||
search: '',
|
||||
displayInNav: false,
|
||||
options: {
|
||||
read: 'full',
|
||||
write: '-',
|
||||
@@ -102,6 +133,7 @@ describe('getRouteFromPreset()', () => {
|
||||
alias: 'cuesheet-basic',
|
||||
target: OntimeView.Cuesheet,
|
||||
search: '',
|
||||
displayInNav: false,
|
||||
},
|
||||
];
|
||||
const cuesheetPresetWithNavLock: URLPreset[] = [
|
||||
@@ -110,6 +142,7 @@ describe('getRouteFromPreset()', () => {
|
||||
alias: 'cuesheet-locked',
|
||||
target: OntimeView.Cuesheet,
|
||||
search: 'n=1',
|
||||
displayInNav: false,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -191,6 +224,7 @@ describe('generateUrlPresetOptions', () => {
|
||||
target: 'timer',
|
||||
search: 'param1=value1¶m2=value2',
|
||||
enabled: true,
|
||||
displayInNav: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -202,6 +236,7 @@ describe('generateUrlPresetOptions', () => {
|
||||
target: 'timer',
|
||||
search: 'param1=value1¶m2=value2',
|
||||
enabled: true,
|
||||
displayInNav: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -213,6 +248,7 @@ describe('generateUrlPresetOptions', () => {
|
||||
target: 'timer',
|
||||
search: 'param1=value1¶m2=value2',
|
||||
enabled: true,
|
||||
displayInNav: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -224,6 +260,7 @@ describe('generateUrlPresetOptions', () => {
|
||||
target: 'timer',
|
||||
search: 'param1=value1¶m2=value2',
|
||||
enabled: true,
|
||||
displayInNav: false,
|
||||
},
|
||||
],
|
||||
])('should generate URL preset options for %s', (_description, alias, url, expected) => {
|
||||
|
||||
@@ -3,9 +3,7 @@ import {
|
||||
Log,
|
||||
MaybeNumber,
|
||||
MessageTag,
|
||||
ProjectRundownsList,
|
||||
RefetchKey,
|
||||
Rundown,
|
||||
RuntimeStore,
|
||||
WsPacketToClient,
|
||||
WsPacketToServer,
|
||||
@@ -16,16 +14,18 @@ import {
|
||||
APP_SETTINGS,
|
||||
CLIENT_LIST,
|
||||
CSS_OVERRIDE,
|
||||
CURRENT_RUNDOWN_QUERY_KEY,
|
||||
CUSTOM_FIELDS,
|
||||
PROJECT_DATA,
|
||||
CURRENT_RUNDOWN_QUERY_KEY,
|
||||
PROJECT_RUNDOWNS,
|
||||
REPORT,
|
||||
RUNDOWN,
|
||||
RUNTIME,
|
||||
TRANSLATION,
|
||||
URL_PRESETS,
|
||||
VIEW_SETTINGS,
|
||||
getRundownQueryKey,
|
||||
PROJECT_RUNDOWNS,
|
||||
PROJECT_LIST,
|
||||
} from '../api/constants';
|
||||
import { invalidateAllCaches } from '../api/utils';
|
||||
import { ontimeQueryClient } from '../queryClient';
|
||||
@@ -51,8 +51,10 @@ const socketConfig = {
|
||||
offlineAttemptsThreshold: 2, // when we consider the client disconnected
|
||||
} as const;
|
||||
|
||||
export let hasConnected = false;
|
||||
export let reconnectAttempts = 0;
|
||||
export const getConnectionState = () => hasConnected;
|
||||
export const getReconnectAttempts = () => reconnectAttempts;
|
||||
let hasConnected = false;
|
||||
let reconnectAttempts = 0;
|
||||
|
||||
export const connectSocket = () => {
|
||||
websocket = new WebSocket(websocketUrl);
|
||||
@@ -179,7 +181,7 @@ export const connectSocket = () => {
|
||||
}
|
||||
case MessageTag.Refetch: {
|
||||
// the refetch message signals that the rundown has changed in the server side
|
||||
const { target, revision } = payload;
|
||||
const { target, revision, rundownId } = payload;
|
||||
switch (target) {
|
||||
case RefetchKey.All:
|
||||
invalidateAllCaches();
|
||||
@@ -194,7 +196,7 @@ export const connectSocket = () => {
|
||||
ontimeQueryClient.invalidateQueries({ queryKey: REPORT });
|
||||
break;
|
||||
case RefetchKey.Rundown: {
|
||||
maybeInvalidateRundownCache(revision);
|
||||
maybeInvalidateRundownCache(revision, rundownId);
|
||||
break;
|
||||
}
|
||||
case RefetchKey.UrlPresets:
|
||||
@@ -212,6 +214,12 @@ export const connectSocket = () => {
|
||||
case RefetchKey.Settings:
|
||||
ontimeQueryClient.invalidateQueries({ queryKey: APP_SETTINGS });
|
||||
break;
|
||||
case RefetchKey.ProjectRundowns:
|
||||
ontimeQueryClient.invalidateQueries({ queryKey: PROJECT_RUNDOWNS });
|
||||
break;
|
||||
case RefetchKey.ProjectFiles:
|
||||
ontimeQueryClient.invalidateQueries({ queryKey: PROJECT_LIST });
|
||||
break;
|
||||
default: {
|
||||
target satisfies never;
|
||||
break;
|
||||
@@ -230,34 +238,30 @@ export const connectSocket = () => {
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* When we receive a refetch message for the rundown
|
||||
* check which rundown needs to be invalidated
|
||||
*/
|
||||
export function maybeInvalidateRundownCache(revision: MaybeNumber) {
|
||||
const loadedRundownId: string | undefined = (ontimeQueryClient.getQueryData(PROJECT_RUNDOWNS) as ProjectRundownsList)
|
||||
?.loaded;
|
||||
|
||||
const activeRundownQueryKey = loadedRundownId ? getRundownQueryKey(loadedRundownId) : CURRENT_RUNDOWN_QUERY_KEY;
|
||||
const cachedRundown = ontimeQueryClient.getQueryData<Rundown>(activeRundownQueryKey);
|
||||
if (revision === cachedRundown?.revision) {
|
||||
export function maybeInvalidateRundownCache(revision: MaybeNumber, rundownId?: string) {
|
||||
if (!rundownId) {
|
||||
// we omit rundownId to signify invalidate all rundowns
|
||||
ontimeQueryClient.invalidateQueries({ queryKey: RUNDOWN });
|
||||
ontimeQueryClient.invalidateQueries({ queryKey: CURRENT_RUNDOWN_QUERY_KEY, exact: true });
|
||||
return;
|
||||
}
|
||||
|
||||
ontimeQueryClient.invalidateQueries({ queryKey: activeRundownQueryKey, exact: true });
|
||||
// skip if we dont recognise the ID the revision is lower
|
||||
const queryKey = getRundownQueryKey(rundownId);
|
||||
const cachedRundown = ontimeQueryClient.getQueryData<{ revision: number }>(queryKey);
|
||||
|
||||
if (loadedRundownId) {
|
||||
// Keep bootstrap alias in sync with the ID-based cache
|
||||
ontimeQueryClient.invalidateQueries({ queryKey: CURRENT_RUNDOWN_QUERY_KEY, exact: true });
|
||||
} else {
|
||||
// During bootstrap, loadedRundownId is not yet known.
|
||||
// Invalidate any ID-based rundown caches that may have been seeded early.
|
||||
ontimeQueryClient.invalidateQueries({
|
||||
predicate: (query) => query.queryKey[0] === 'rundown' && query.queryKey[1] !== 'current',
|
||||
});
|
||||
if (revision === cachedRundown?.revision) {
|
||||
// we already have the latest change
|
||||
return;
|
||||
}
|
||||
|
||||
ontimeQueryClient.invalidateQueries({ queryKey: CUSTOM_FIELDS });
|
||||
ontimeQueryClient.invalidateQueries({ queryKey, exact: true });
|
||||
|
||||
// keep current alias in sync with the ID-based cache
|
||||
const loadedRundownId = ontimeQueryClient.getQueryData<{ loaded: string }>(PROJECT_RUNDOWNS)?.loaded;
|
||||
if (!loadedRundownId || loadedRundownId === rundownId) {
|
||||
ontimeQueryClient.invalidateQueries({ queryKey: CURRENT_RUNDOWN_QUERY_KEY, exact: true });
|
||||
}
|
||||
}
|
||||
|
||||
export function sendSocket<T extends MessageTag | ApiActionTag>(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { MaybeNumber, OntimeEvent, Settings, TimeFormat } from 'ontime-types';
|
||||
import { MaybeNumber, MaybeString, OntimeEvent, Settings, TimeFormat } from 'ontime-types';
|
||||
import {
|
||||
MILLIS_PER_HOUR,
|
||||
MILLIS_PER_MINUTE,
|
||||
@@ -75,8 +75,9 @@ function resolveTimeFormat(fallback12: string, fallback24: string): string {
|
||||
}
|
||||
|
||||
type FormatOptions = {
|
||||
format12: string;
|
||||
format24: string;
|
||||
format12?: string;
|
||||
format24?: string;
|
||||
override?: MaybeString;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -97,7 +98,7 @@ export const formatTime = (
|
||||
return '...';
|
||||
}
|
||||
|
||||
const timeFormat = resolver(options?.format12 ?? FORMAT_12, options?.format24 ?? FORMAT_24);
|
||||
const timeFormat = options?.override ?? resolver(options?.format12 ?? FORMAT_12, options?.format24 ?? FORMAT_24);
|
||||
const display = formatFromMillis(Math.abs(milliseconds), timeFormat);
|
||||
|
||||
const isNegative = milliseconds < 0;
|
||||
|
||||
@@ -194,6 +194,7 @@ export function generateUrlPresetOptions(alias: string, userUrl: string): URLPre
|
||||
target: path,
|
||||
search: url.searchParams.toString(),
|
||||
enabled: true,
|
||||
displayInNav: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -204,7 +205,7 @@ function isPresettableView(view: string): view is OntimeViewPresettable {
|
||||
/**
|
||||
* Check if current location is a preset path
|
||||
*/
|
||||
export function isPresetPath(location: Path): boolean {
|
||||
const segments = location.pathname.split('/').filter(Boolean);
|
||||
return segments[0] === 'preset';
|
||||
function isPresetPath(location: Path): boolean {
|
||||
const firstSegment = location.pathname.split('/').find(Boolean);
|
||||
return firstSegment === 'preset';
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ export const apiRepoLatest = 'https://api.github.com/repos/cpvalente/ontime/rele
|
||||
export const websiteUrl = 'https://www.getontime.no';
|
||||
export const discordUrl = 'https://discord.com/invite/eje3CSUEXm';
|
||||
export const subredditUrl = 'https://www.reddit.com/r/ontimeapp/';
|
||||
export const youtubeUrl = 'https://www.youtube.com/@ontimeapp';
|
||||
|
||||
export const documentationUrl = 'https://docs.getontime.no';
|
||||
export const customFieldsDocsUrl = 'https://docs.getontime.no/features/custom-fields/';
|
||||
|
||||
@@ -53,10 +53,6 @@ ul {
|
||||
background-color: $red-400;
|
||||
}
|
||||
}
|
||||
|
||||
&.split {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.secondary {
|
||||
|
||||
@@ -42,16 +42,9 @@ interface PanelListItemProps {
|
||||
location?: string;
|
||||
}
|
||||
|
||||
function PanelListItem(props: PanelListItemProps) {
|
||||
const { panel, isSelected, location } = props;
|
||||
function PanelListItem({ panel, isSelected, location }: PanelListItemProps) {
|
||||
const { setLocation } = useAppSettingsNavigation();
|
||||
|
||||
const classes = cx([
|
||||
style.primary,
|
||||
isSelected && style.active,
|
||||
panel.split && style.split,
|
||||
panel.highlight && style.highlight,
|
||||
]);
|
||||
const classes = cx([style.primary, isSelected && style.active, panel.highlight && style.highlight]);
|
||||
|
||||
return (
|
||||
<Fragment key={panel.id}>
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
githubUrl,
|
||||
subredditUrl,
|
||||
websiteUrl,
|
||||
youtubeUrl,
|
||||
} from '../../../../externals';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
import AppVersion from './AppVersion';
|
||||
@@ -38,6 +39,7 @@ export default function AboutPanel() {
|
||||
<ExternalLink href={documentationUrl}>Read the docs</ExternalLink>
|
||||
<ExternalLink href={githubUrl}>Follow the project on GitHub</ExternalLink>
|
||||
<ExternalLink href={discordUrl}>Discord server</ExternalLink>
|
||||
<ExternalLink href={youtubeUrl}>YouTube channel</ExternalLink>
|
||||
<ExternalLink href={subredditUrl}>Subreddit</ExternalLink>
|
||||
</Panel.Section>
|
||||
</>
|
||||
|
||||
+3
-4
@@ -89,10 +89,9 @@ export default function AutomationSettingsForm({
|
||||
|
||||
<Panel.Section>
|
||||
<Info>
|
||||
<p>Control Ontime and share its data with external systems in your workflow.</p>
|
||||
<p>- Automations allow Ontime to send its data on lifecycle triggers.</p>
|
||||
<p>- OSC Input tells Ontime to listen to messages on the specific port.</p>
|
||||
<br />
|
||||
<span>Control Ontime and share its data with external systems in your workflow.</span>
|
||||
<span>- Automations allow Ontime to send its data on lifecycle triggers.</span>
|
||||
<span>- OSC Input tells Ontime to listen to messages on the specific port.</span>
|
||||
<ExternalLink href={oscApiDocsUrl}>See the docs</ExternalLink>
|
||||
</Info>
|
||||
</Panel.Section>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { URLPreset } from 'ontime-types';
|
||||
import { OntimeView, URLPreset } from 'ontime-types';
|
||||
import { useState } from 'react';
|
||||
import { IoAdd, IoOpenOutline, IoPencil, IoTrash } from 'react-icons/io5';
|
||||
|
||||
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';
|
||||
@@ -22,13 +23,23 @@ const urlPresetsDocs = 'https://docs.getontime.no/features/url-presets/';
|
||||
|
||||
export default function URLPresets() {
|
||||
const [formState, setFormState] = useState<FormState>({ isOpen: false, preset: undefined });
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const { data, status } = useUrlPresets();
|
||||
const { deletePreset, isMutating } = useUpdateUrlPreset();
|
||||
const { updatePreset, deletePreset, isMutating } = useUpdateUrlPreset();
|
||||
|
||||
const openNewForm = () => setFormState({ isOpen: true });
|
||||
const openEditForm = (preset: URLPreset) => setFormState({ isOpen: true, preset });
|
||||
const closeForm = () => setFormState({ isOpen: false, preset: undefined });
|
||||
|
||||
const persistPreset = async (preset: URLPreset) => {
|
||||
setActionError(null);
|
||||
try {
|
||||
await updatePreset(preset.alias, preset);
|
||||
} catch (error) {
|
||||
setActionError(maybeAxiosError(error));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Panel.Section>
|
||||
<Panel.Card>
|
||||
@@ -53,10 +64,12 @@ export default function URLPresets() {
|
||||
<Panel.Section>
|
||||
<Panel.Loader isLoading={status === 'pending'} />
|
||||
{formState.isOpen && <URLPresetForm urlPreset={formState.preset} onClose={closeForm} />}
|
||||
{actionError && <Panel.Error>{actionError}</Panel.Error>}
|
||||
<Panel.Table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Enabled</th>
|
||||
<th>Show in nav</th>
|
||||
<th>Target view</th>
|
||||
<th>Alias</th>
|
||||
<th />
|
||||
@@ -65,10 +78,23 @@ export default function URLPresets() {
|
||||
<tbody>
|
||||
{data.length === 0 && <Panel.TableEmpty handleClick={openNewForm} />}
|
||||
{data.map((preset, index) => {
|
||||
const isCuesheet = preset.target === OntimeView.Cuesheet;
|
||||
return (
|
||||
<tr key={preset.alias}>
|
||||
<td>
|
||||
<Switch defaultChecked={preset.enabled} onCheckedChange={() => {}} />
|
||||
<Switch
|
||||
checked={preset.enabled}
|
||||
onCheckedChange={(enabled) => persistPreset({ ...preset, enabled })}
|
||||
disabled={isMutating}
|
||||
aria-label='Toggle preset enabled'
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<Switch
|
||||
checked={preset.displayInNav}
|
||||
onCheckedChange={(checked) => persistPreset({ ...preset, displayInNav: checked })}
|
||||
disabled={isMutating || isCuesheet}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<Tag>{preset.target}</Tag>
|
||||
|
||||
+88
-33
@@ -1,5 +1,5 @@
|
||||
import { OntimeView, OntimeViewPresettable, URLPreset } from 'ontime-types';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
import { maybeAxiosError, unwrapError } from '../../../../../common/api/utils';
|
||||
@@ -12,6 +12,7 @@ import { preventEscape } from '../../../../../common/utils/keyEvent';
|
||||
import { isUrlSafe } from '../../../../../common/utils/regex';
|
||||
import { enDash } from '../../../../../common/utils/styleUtils';
|
||||
import { generateUrlPresetOptions } from '../../../../../common/utils/urlPresets';
|
||||
import CuesheetLinkOptions, { CuesheetPermissionValues } from '../../../../sharing/composite/CuesheetLinkOptions';
|
||||
import * as Panel from '../../../panel-utils/PanelUtils';
|
||||
|
||||
import style from './URLPresetForm.module.scss';
|
||||
@@ -32,6 +33,7 @@ const defaultValues: URLPreset = {
|
||||
target: OntimeView.Timer,
|
||||
search: '',
|
||||
enabled: true,
|
||||
displayInNav: false,
|
||||
};
|
||||
|
||||
interface URLPresetFormProps {
|
||||
@@ -61,12 +63,42 @@ export default function URLPresetForm({ urlPreset, onClose }: URLPresetFormProps
|
||||
});
|
||||
const urlRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Cuesheet read/write permissions live outside react-hook-form
|
||||
const initialPermissions = useRef<CuesheetPermissionValues>({
|
||||
read: urlPreset?.options?.read ?? 'full',
|
||||
write: urlPreset?.options?.write ?? 'full',
|
||||
});
|
||||
const [cuesheetPermissions, setCuesheetPermissions] = useState<CuesheetPermissionValues>(initialPermissions.current);
|
||||
|
||||
// update initial permissions on mount
|
||||
useEffect(() => {
|
||||
initialPermissions.current = {
|
||||
read: urlPreset?.options?.read ?? 'full',
|
||||
write: urlPreset?.options?.write ?? 'full',
|
||||
};
|
||||
setCuesheetPermissions(initialPermissions.current);
|
||||
// oxlint-disable-next-line eslint-plugin-react-hooks/exhaustive-deps -- run on mount
|
||||
}, []);
|
||||
|
||||
const isEditingCuesheet = urlPreset && urlPreset.target === OntimeView.Cuesheet;
|
||||
const isCuesheet = watch('target') === OntimeView.Cuesheet;
|
||||
const permissionsDirty =
|
||||
isCuesheet &&
|
||||
(cuesheetPermissions.read !== initialPermissions.current.read ||
|
||||
cuesheetPermissions.write !== initialPermissions.current.write);
|
||||
const noReadAccess = isCuesheet && cuesheetPermissions.read === '-';
|
||||
|
||||
const setupSubmit = async (data: URLPreset) => {
|
||||
try {
|
||||
// Preserve / apply cuesheet permissions, which are not part of the form fields
|
||||
const payload: URLPreset =
|
||||
data.target === OntimeView.Cuesheet
|
||||
? { ...data, target: OntimeView.Cuesheet, options: cuesheetPermissions }
|
||||
: data;
|
||||
if (urlPreset) {
|
||||
await updatePreset(urlPreset.alias, data);
|
||||
await updatePreset(urlPreset.alias, payload);
|
||||
} else {
|
||||
await addPreset(data);
|
||||
await addPreset(payload);
|
||||
}
|
||||
onClose();
|
||||
} catch (error) {
|
||||
@@ -74,6 +106,7 @@ export default function URLPresetForm({ urlPreset, onClose }: URLPresetFormProps
|
||||
}
|
||||
};
|
||||
|
||||
// focus on alias when the form opens
|
||||
useEffect(() => {
|
||||
setFocus('alias');
|
||||
}, [setFocus]);
|
||||
@@ -126,44 +159,66 @@ export default function URLPresetForm({ urlPreset, onClose }: URLPresetFormProps
|
||||
<div className={style.expand}>
|
||||
<Panel.Description>Generate options (paste URL to generate options)</Panel.Description>
|
||||
<Panel.InlineElements>
|
||||
<Input placeholder='Paste URL' fluid ref={urlRef} />
|
||||
<Button onClick={generateOptions}>Generate</Button>
|
||||
<Input placeholder='Paste URL' fluid ref={urlRef} disabled={isEditingCuesheet} />
|
||||
<Button onClick={generateOptions} disabled={isEditingCuesheet}>
|
||||
Generate
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
</div>
|
||||
</Panel.InlineElements>
|
||||
{errors.alias?.message && <Panel.Error>{errors.alias.message}</Panel.Error>}
|
||||
<div>
|
||||
{enDash} or {enDash}
|
||||
</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')}
|
||||
onValueChange={(value: OntimeViewPresettable | null) => {
|
||||
if (value === null) return;
|
||||
setValue('target', value, { shouldDirty: true });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Panel.Description>Parameters</Panel.Description>
|
||||
<Textarea
|
||||
fluid
|
||||
rows={3}
|
||||
{...register('search', {
|
||||
validate: validateParams,
|
||||
})}
|
||||
/>
|
||||
<Panel.Error>{errors.search?.message}</Panel.Error>
|
||||
</div>
|
||||
{!isEditingCuesheet && (
|
||||
<>
|
||||
<div>
|
||||
{enDash} or {enDash}
|
||||
</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')}
|
||||
onValueChange={(value: OntimeViewPresettable | null) => {
|
||||
if (value === null) return;
|
||||
setValue('target', value, { shouldDirty: true });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Panel.Description>Parameters</Panel.Description>
|
||||
<Textarea
|
||||
fluid
|
||||
rows={3}
|
||||
{...register('search', {
|
||||
validate: validateParams,
|
||||
})}
|
||||
/>
|
||||
<Panel.Error>{errors.search?.message}</Panel.Error>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isCuesheet && (
|
||||
<div>
|
||||
<Panel.Description>Permissions</Panel.Description>
|
||||
<CuesheetLinkOptions
|
||||
initialRead={initialPermissions.current.read}
|
||||
initialWrite={initialPermissions.current.write}
|
||||
onChange={setCuesheetPermissions}
|
||||
/>
|
||||
</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}>
|
||||
<Button
|
||||
variant='primary'
|
||||
type='submit'
|
||||
disabled={!isValid || (!isDirty && !permissionsDirty) || noReadAccess}
|
||||
loading={isSubmitting || isMutating}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
|
||||
@@ -57,11 +57,11 @@ export default function CustomFieldSettings() {
|
||||
<Panel.Divider />
|
||||
<Panel.Section>
|
||||
<Info>
|
||||
Custom fields allow for additional information to be added to an event.
|
||||
<br />
|
||||
<br />
|
||||
To use custom fields as a data source in an{' '}
|
||||
<AppLink search='settings=automation__automations'>Automation</AppLink>, please note the generated key.
|
||||
<span>Custom fields allow for additional information to be added to an event.</span>
|
||||
<span>
|
||||
To use custom fields as a data source in an{' '}
|
||||
<AppLink search='settings=automation__automations'>Automation</AppLink>, please note the generated key.
|
||||
</span>
|
||||
<ExternalLink href={customFieldsDocsUrl}>See the docs</ExternalLink>
|
||||
</Info>
|
||||
</Panel.Section>
|
||||
|
||||
@@ -52,8 +52,9 @@ export default function CustomViews() {
|
||||
|
||||
<Panel.Section>
|
||||
<Info>
|
||||
Upload one <strong>index.html</strong> per view to <strong>/external/<name>/</strong>.
|
||||
<br />
|
||||
<span>
|
||||
Upload one <strong>index.html</strong> per view to <strong>/external/<name>/</strong>.
|
||||
</span>
|
||||
External imports are not allowed, include all assets inside the html file.
|
||||
<ExternalLink href={customViewsDocs}>See the docs</ExternalLink>
|
||||
</Info>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { useState } from 'react';
|
||||
import { Suspense, useState } from 'react';
|
||||
import {
|
||||
IoAdd,
|
||||
IoDocumentOutline,
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
IoPencilOutline,
|
||||
IoTrash,
|
||||
} from 'react-icons/io5';
|
||||
import { TbGhost3 } from 'react-icons/tb';
|
||||
|
||||
import { downloadAsExcel } from '../../../../common/api/excel';
|
||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||
@@ -19,6 +20,7 @@ import { DropdownMenu } from '../../../../common/components/dropdown-menu/Dropdo
|
||||
import Tag from '../../../../common/components/tag/Tag';
|
||||
import { useMutateProjectRundowns, useProjectRundowns } from '../../../../common/hooks-query/useProjectRundowns';
|
||||
import { cx } from '../../../../common/utils/styleUtils';
|
||||
import { useDirectLinkToBackgroundEdit } from '../../../../views/cuesheet/useCuesheetRundownSelection';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
import RundownRenameForm from './composite/RundownRenameForm';
|
||||
import { ManageRundownForm } from './ManageRundownForm';
|
||||
@@ -26,6 +28,20 @@ import { ManageRundownForm } from './ManageRundownForm';
|
||||
import style from './ManagePanel.module.scss';
|
||||
|
||||
export default function ManageRundowns() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className={style.empty}>
|
||||
<Panel.Loader isLoading />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ManageRundownsSuspense />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function ManageRundownsSuspense() {
|
||||
const { data } = useProjectRundowns();
|
||||
const { duplicate, remove, load, rename } = useMutateProjectRundowns();
|
||||
const [isOpenDelete, deleteHandlers] = useDisclosure();
|
||||
@@ -97,6 +113,8 @@ export default function ManageRundowns() {
|
||||
await downloadAsExcel(rundownId, title);
|
||||
};
|
||||
|
||||
const navigateToCuesheet = useDirectLinkToBackgroundEdit();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Panel.Section>
|
||||
@@ -153,7 +171,7 @@ export default function ManageRundowns() {
|
||||
</td>
|
||||
<td>
|
||||
<DropdownMenu
|
||||
render={<IconButton variant='ghosted-white' />}
|
||||
render={<IconButton variant='ghosted-white' data-testId='rundown_menu' />}
|
||||
items={[
|
||||
{
|
||||
type: 'item',
|
||||
@@ -180,6 +198,12 @@ export default function ManageRundowns() {
|
||||
label: 'Duplicate',
|
||||
onClick: () => submitRundownDuplicate(id),
|
||||
},
|
||||
{
|
||||
type: 'item',
|
||||
icon: TbGhost3,
|
||||
label: 'Edit in cuesheet',
|
||||
onClick: () => navigateToCuesheet(id),
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
type: 'destructive',
|
||||
|
||||
@@ -16,6 +16,7 @@ export default function RundownDefaultSettings() {
|
||||
defaultDangerTime,
|
||||
defaultTimerType,
|
||||
defaultEndAction,
|
||||
inheritGroupColour,
|
||||
setDefaultDuration,
|
||||
setLinkPrevious,
|
||||
setTimeStrategy,
|
||||
@@ -23,6 +24,7 @@ export default function RundownDefaultSettings() {
|
||||
setDangerTime,
|
||||
setDefaultTimerType,
|
||||
setDefaultEndAction,
|
||||
setInheritGroupColour,
|
||||
} = useEditorSettings((state) => state);
|
||||
|
||||
const durationInMs = parseUserTime(defaultDuration);
|
||||
@@ -44,6 +46,13 @@ export default function RundownDefaultSettings() {
|
||||
/>
|
||||
<Switch size='large' checked={linkPrevious} onCheckedChange={setLinkPrevious} />
|
||||
</Panel.ListItem>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
title='Inherit group colour'
|
||||
description='Whether new events and milestones inherit the colour of their parent group'
|
||||
/>
|
||||
<Switch size='large' checked={inheritGroupColour} onCheckedChange={setInheritGroupColour} />
|
||||
</Panel.ListItem>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
title='Timer strategy'
|
||||
|
||||
+26
-19
@@ -15,23 +15,25 @@ import Input from '../../../../../common/components/input/input/Input';
|
||||
import Tag from '../../../../../common/components/tag/Tag';
|
||||
import { openLink } from '../../../../../common/utils/linkUtils';
|
||||
import * as Panel from '../../../panel-utils/PanelUtils';
|
||||
import { extractSheetId, getPersistedSheetId, persistSheetId } from './gsheetUtils';
|
||||
|
||||
import style from './SourcesPanel.module.scss';
|
||||
|
||||
interface GSheetSetupProps {
|
||||
onCancel: () => void;
|
||||
onSheetLoaded: (sheetId: string, options: SpreadsheetWorksheetOptions) => void;
|
||||
closedByUser: boolean;
|
||||
}
|
||||
|
||||
export default function GSheetSetup(props: GSheetSetupProps) {
|
||||
const { onCancel, onSheetLoaded } = props;
|
||||
const { onCancel, onSheetLoaded, closedByUser } = props;
|
||||
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [sheetId, setSheetId] = useState('');
|
||||
const [sheetId, setSheetId] = useState(getPersistedSheetId);
|
||||
const [authenticationStatus, setAuthenticationStatus] = useState<AuthenticationStatus>('not_authenticated');
|
||||
const [authKey, setAuthKey] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState<'' | 'cancel' | 'connect' | 'authenticate' | 'load-sheet'>('');
|
||||
const [authLink, setAuthLink] = useState('');
|
||||
const [loading, setLoading] = useState<'' | 'cancel' | 'connect' | 'authenticate' | 'load-sheet'>('');
|
||||
const [authError, setAuthError] = useState('');
|
||||
const [worksheetError, setWorksheetError] = useState('');
|
||||
const pollTimeoutRef = useRef<number | null>(null);
|
||||
@@ -62,6 +64,7 @@ export default function GSheetSetup(props: GSheetSetupProps) {
|
||||
const loadWorksheetOptions = useCallback(
|
||||
async (nextSheetId: string) => {
|
||||
const worksheetOptions = await getWorksheetOptions(nextSheetId);
|
||||
persistSheetId(nextSheetId);
|
||||
onSheetLoaded(nextSheetId, worksheetOptions);
|
||||
setWorksheetError('');
|
||||
},
|
||||
@@ -71,6 +74,7 @@ export default function GSheetSetup(props: GSheetSetupProps) {
|
||||
const pollUntilAuthenticated = useCallback(
|
||||
async (attempts: number = 0) => {
|
||||
clearPollTimeout();
|
||||
if (closedByUser) return;
|
||||
|
||||
try {
|
||||
const result = await verifyAuthenticationStatus();
|
||||
@@ -82,13 +86,16 @@ export default function GSheetSetup(props: GSheetSetupProps) {
|
||||
pollTimeoutRef.current = window.setTimeout(() => {
|
||||
pollUntilAuthenticated(attempts + 1);
|
||||
}, 2000);
|
||||
} else {
|
||||
setLoading('');
|
||||
return; // Keep authKey for next poll
|
||||
}
|
||||
// Polling timed out
|
||||
setAuthKey(null);
|
||||
setLoading('');
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.authenticated === 'authenticated') {
|
||||
if (result.authenticated === 'authenticated' && result.sheetId) {
|
||||
setLoading('load-sheet');
|
||||
try {
|
||||
await loadWorksheetOptions(result.sheetId);
|
||||
} catch (error) {
|
||||
@@ -96,13 +103,15 @@ export default function GSheetSetup(props: GSheetSetupProps) {
|
||||
}
|
||||
}
|
||||
|
||||
setAuthKey(null);
|
||||
setLoading('');
|
||||
} catch (error) {
|
||||
setAuthError(maybeAxiosError(error));
|
||||
setAuthKey(null);
|
||||
setLoading('');
|
||||
}
|
||||
},
|
||||
[clearPollTimeout, loadWorksheetOptions],
|
||||
[clearPollTimeout, loadWorksheetOptions, closedByUser],
|
||||
);
|
||||
|
||||
/** check if the current session has been authenticated */
|
||||
@@ -150,7 +159,7 @@ export default function GSheetSetup(props: GSheetSetupProps) {
|
||||
};
|
||||
|
||||
/**
|
||||
* Requests connection to google auth
|
||||
* Requests a device code from Google. The user can copy it before opening the browser.
|
||||
*/
|
||||
const handleConnect = async () => {
|
||||
if (!file) return;
|
||||
@@ -171,7 +180,7 @@ export default function GSheetSetup(props: GSheetSetupProps) {
|
||||
};
|
||||
|
||||
/**
|
||||
* Open google auth
|
||||
* Opens the Google verification page and starts polling for completion.
|
||||
*/
|
||||
const handleAuthenticate = () => {
|
||||
setLoading('authenticate');
|
||||
@@ -180,7 +189,6 @@ export default function GSheetSetup(props: GSheetSetupProps) {
|
||||
clearPollTimeout();
|
||||
clearAuthFallbackTimeout();
|
||||
|
||||
// open link and schedule a check for when the user focuses again
|
||||
openLink(authLink);
|
||||
authFallbackTimeoutRef.current = window.setTimeout(() => {
|
||||
if (document.hasFocus()) {
|
||||
@@ -269,18 +277,19 @@ export default function GSheetSetup(props: GSheetSetupProps) {
|
||||
</Panel.ListGroup>
|
||||
)}
|
||||
<Panel.ListGroup className={style.setupBlock}>
|
||||
<Panel.Description>Enter ID of sheet to synchronise</Panel.Description>
|
||||
<Panel.Description>Enter ID of sheet to synchronize</Panel.Description>
|
||||
<Panel.Error>{worksheetError}</Panel.Error>
|
||||
<Input
|
||||
fluid
|
||||
value={sheetId}
|
||||
placeholder='Sheet ID'
|
||||
placeholder='Sheet ID or Google Sheets URL'
|
||||
onChange={(event) => {
|
||||
setWorksheetError('');
|
||||
setSheetId(event.target.value);
|
||||
setSheetId(extractSheetId(event.target.value));
|
||||
}}
|
||||
disabled={isLoading || canAuthenticate}
|
||||
/>
|
||||
<div className={style.setupHint}>Paste a Google Sheets URL or the sheet ID from the URL bar.</div>
|
||||
</Panel.ListGroup>
|
||||
{isAuthenticated ? (
|
||||
<Panel.ListGroup className={style.setupBlock}>
|
||||
@@ -304,18 +313,16 @@ export default function GSheetSetup(props: GSheetSetupProps) {
|
||||
</Panel.ListGroup>
|
||||
) : (
|
||||
<Panel.ListGroup className={style.setupBlock}>
|
||||
<Panel.Description>Authenticate this Ontime session with Google</Panel.Description>
|
||||
<Panel.Description>Copy the device code, then authenticate with Google</Panel.Description>
|
||||
<Panel.InlineElements wrap='wrap' className={style.setupActions}>
|
||||
{isAuthenticating && <span>Authenticating...</span>}
|
||||
<CopyTag copyValue={authKey ?? ''} disabled={!canAuthenticate}>
|
||||
{authKey ? authKey : 'Upload files to generate Auth Key'}
|
||||
</CopyTag>
|
||||
<Button onClick={handleAuthenticate} disabled={!canAuthenticate}>
|
||||
<CopyTag copyValue={authKey ?? ''}>{authKey}</CopyTag>
|
||||
<Button onClick={handleAuthenticate} disabled={isLoading} loading={loading === 'authenticate'}>
|
||||
<IoShieldCheckmarkOutline />
|
||||
Authenticate
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
<div className={style.setupHint}>Open the browser prompt, complete the code flow, then come back here.</div>
|
||||
<div className={style.setupHint}>Copy the code, then open the browser prompt to complete the flow.</div>
|
||||
</Panel.ListGroup>
|
||||
)}
|
||||
</Panel.Section>
|
||||
|
||||
+27
-8
@@ -37,12 +37,15 @@ type ActiveSource =
|
||||
kind: 'excel';
|
||||
worksheetNames: string[];
|
||||
initialWorksheetMetadata: SpreadsheetWorksheetMetadata | null;
|
||||
closedByUser: boolean;
|
||||
}
|
||||
| {
|
||||
kind: 'gsheet';
|
||||
sheetId: string;
|
||||
worksheetNames: string[];
|
||||
initialWorksheetMetadata: SpreadsheetWorksheetMetadata | null;
|
||||
title: string;
|
||||
closedByUser: boolean;
|
||||
};
|
||||
|
||||
export default function SourcesPanel() {
|
||||
@@ -73,6 +76,7 @@ export default function SourcesPanel() {
|
||||
kind: 'excel',
|
||||
worksheetNames: worksheetOptions.worksheets,
|
||||
initialWorksheetMetadata: worksheetOptions.metadata,
|
||||
closedByUser: false,
|
||||
});
|
||||
setImportFlow('excel');
|
||||
setHasFile('done');
|
||||
@@ -106,7 +110,13 @@ export default function SourcesPanel() {
|
||||
};
|
||||
|
||||
const cancelImportFlow = () => {
|
||||
resetFlow();
|
||||
if (activeSource && activeSource.kind === 'gsheet') {
|
||||
// Return to GSheetSetup so the user can change the sheet ID or revoke auth
|
||||
setActiveSource({ ...activeSource, closedByUser: true });
|
||||
setError('');
|
||||
} else {
|
||||
resetFlow();
|
||||
}
|
||||
};
|
||||
|
||||
const handleFinished = () => {
|
||||
@@ -177,15 +187,22 @@ export default function SourcesPanel() {
|
||||
sheetId,
|
||||
worksheetNames: worksheetOptions.worksheets,
|
||||
initialWorksheetMetadata: worksheetOptions.metadata,
|
||||
title: worksheetOptions.title ?? '',
|
||||
closedByUser: false,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const closedByUser = activeSource?.closedByUser ?? false;
|
||||
const isGSheetFlow = importFlow === 'gsheet';
|
||||
const showInput = importFlow === 'none';
|
||||
const showCompleted = importFlow === 'finished';
|
||||
const showAuth = isGSheetFlow && activeSource === null;
|
||||
const showImportWorkspace = activeSource !== null;
|
||||
const importModalTitle = activeSource?.kind === 'excel' ? 'Import spreadsheet' : 'Synchronise with Google Sheet';
|
||||
|
||||
const showImportWorkspace = activeSource !== null && !closedByUser;
|
||||
const importModalTitle = (() => {
|
||||
if (!activeSource) return '';
|
||||
if (activeSource.kind === 'excel') return 'Import spreadsheet';
|
||||
return activeSource.title ? `Sync: ${activeSource.title}` : 'Synchronize with Google Sheet';
|
||||
})();
|
||||
const sourceKey = (() => {
|
||||
if (!activeSource) return null;
|
||||
if (activeSource.kind === 'excel') return 'excel';
|
||||
@@ -195,7 +212,7 @@ export default function SourcesPanel() {
|
||||
return (
|
||||
<Panel.Section>
|
||||
<Panel.Card>
|
||||
<Panel.SubHeader>Synchronise your rundown with an external source</Panel.SubHeader>
|
||||
<Panel.SubHeader>Synchronize your rundown with an external source</Panel.SubHeader>
|
||||
{error && <Panel.Error>{error}</Panel.Error>}
|
||||
{showInput && (
|
||||
<div className={style.introStack}>
|
||||
@@ -233,7 +250,7 @@ export default function SourcesPanel() {
|
||||
</section>
|
||||
<section className={style.sourceCard}>
|
||||
<div className={style.sourceHeader}>
|
||||
<h4 className={style.sourceTitle}>Synchronise with Google</h4>
|
||||
<h4 className={style.sourceTitle}>Synchronize with Google</h4>
|
||||
</div>
|
||||
<p className={style.sourceDescription}>
|
||||
Connect a Google account once, then load any sheet by ID and keep the import flow inside Ontime.
|
||||
@@ -241,7 +258,7 @@ export default function SourcesPanel() {
|
||||
<div className={style.sourceMeta}>Requires Google OAuth client credentials</div>
|
||||
<Button variant='primary' size='large' fluid onClick={openGSheetFlow} disabled={hasFile !== 'none'}>
|
||||
<IoCloudOutline />
|
||||
Synchronise with Google
|
||||
Synchronize with Google
|
||||
</Button>
|
||||
</section>
|
||||
</div>
|
||||
@@ -257,7 +274,9 @@ export default function SourcesPanel() {
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{showAuth && <GSheetSetup onCancel={cancelGSheetFlow} onSheetLoaded={handleSheetLoaded} />}
|
||||
{isGSheetFlow && (
|
||||
<GSheetSetup onCancel={cancelGSheetFlow} onSheetLoaded={handleSheetLoaded} closedByUser={closedByUser} />
|
||||
)}
|
||||
<Modal
|
||||
isOpen={showImportWorkspace}
|
||||
title={importModalTitle}
|
||||
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { extractSheetId } from '../gsheetUtils';
|
||||
|
||||
describe('extractSheetId()', () => {
|
||||
it('extracts the ID from a full Google Sheets URL', () => {
|
||||
const url = 'https://docs.google.com/spreadsheets/d/1aBcDeFgHiJkLmNoPqRsTuVwXyZ/edit#gid=0';
|
||||
expect(extractSheetId(url)).toBe('1aBcDeFgHiJkLmNoPqRsTuVwXyZ');
|
||||
});
|
||||
|
||||
it('extracts the ID when the URL has no trailing path', () => {
|
||||
expect(extractSheetId('https://docs.google.com/spreadsheets/d/abc123')).toBe('abc123');
|
||||
});
|
||||
|
||||
it('handles IDs with hyphens and underscores', () => {
|
||||
const url = 'https://docs.google.com/spreadsheets/d/1a-B_c2/edit';
|
||||
expect(extractSheetId(url)).toBe('1a-B_c2');
|
||||
});
|
||||
|
||||
it('returns a raw sheet ID unchanged', () => {
|
||||
expect(extractSheetId('1aBcDeFgHiJkLmNoPqRsTuVwXyZ')).toBe('1aBcDeFgHiJkLmNoPqRsTuVwXyZ');
|
||||
});
|
||||
|
||||
it('trims whitespace from the input', () => {
|
||||
expect(extractSheetId(' 1aBcDeFg ')).toBe('1aBcDeFg');
|
||||
});
|
||||
|
||||
it('trims whitespace from a pasted URL', () => {
|
||||
const url = ' https://docs.google.com/spreadsheets/d/1aBcDeFg/edit ';
|
||||
expect(extractSheetId(url)).toBe('1aBcDeFg');
|
||||
});
|
||||
|
||||
it('strips query params from a raw ID', () => {
|
||||
expect(extractSheetId('1aBcDeFgHiJkLmNoPqRsTuVwXyZ?edit=1')).toBe('1aBcDeFgHiJkLmNoPqRsTuVwXyZ');
|
||||
});
|
||||
|
||||
it('strips fragment from a raw ID', () => {
|
||||
expect(extractSheetId('1aBcDeFg#gid=0')).toBe('1aBcDeFg');
|
||||
});
|
||||
|
||||
it('returns empty string for empty input', () => {
|
||||
expect(extractSheetId('')).toBe('');
|
||||
expect(extractSheetId(' ')).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { makeStageKey } from '../../../../../common/utils/localStorage';
|
||||
|
||||
// Matches the document ID segment from a Google Sheets URL:
|
||||
// https://docs.google.com/spreadsheets/d/{ID}/edit#gid=0
|
||||
// ^^^^ captured group
|
||||
// IDs consist of alphanumeric chars, hyphens, and underscores.
|
||||
const sheetIdPattern = /\/spreadsheets\/d\/([a-zA-Z0-9_-]+)/;
|
||||
const lastSheetIdKey = makeStageKey('gsheet:lastSheetId');
|
||||
|
||||
/**
|
||||
* Extracts a Google Sheet ID from a full URL, or returns the raw input if it doesn't match.
|
||||
*/
|
||||
export function extractSheetId(input: string): string {
|
||||
const trimmed = input.trim();
|
||||
const match = trimmed.match(sheetIdPattern);
|
||||
if (match) return match[1];
|
||||
|
||||
// Strip query params (?...) and fragments (#...) in case the user
|
||||
// pasted a partial URL or an ID with trailing junk like "abc123?edit=1"
|
||||
return trimmed.split(/[?#]/)[0];
|
||||
}
|
||||
|
||||
export function getPersistedSheetId(): string {
|
||||
return localStorage.getItem(lastSheetIdKey) ?? '';
|
||||
}
|
||||
|
||||
export function persistSheetId(sheetId: string) {
|
||||
localStorage.setItem(lastSheetIdKey, sheetId);
|
||||
}
|
||||
+1
-1
@@ -16,7 +16,7 @@ export function getWarningText(warning: MappingWarning): string {
|
||||
case 'invalid-name':
|
||||
return 'Column cannot be converted into an Ontime field name';
|
||||
case 'name-collision':
|
||||
return 'Column name resolves to a duplicate column';
|
||||
return 'Column name resolves to a duplicate Ontime field';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
|
||||
+1
-1
@@ -50,7 +50,7 @@ export default function SheetImportMappingPane({
|
||||
return (
|
||||
<section className={style.mappingPane}>
|
||||
<Panel.InlineElements align='apart' className={style.mappingPaneHeader}>
|
||||
<span className={style.mappingPaneTitle}>Fields</span>
|
||||
<span className={style.mappingPaneTitle}>Column mapping</span>
|
||||
<Panel.InlineElements relation='inner' className={style.mappingPaneActions}>
|
||||
<Button className={style.addColumnTrigger} onClick={handleAddCustomField} disabled={isBusy}>
|
||||
<IoAdd />
|
||||
|
||||
+11
-3
@@ -58,7 +58,7 @@ describe('getImportWarnings()', () => {
|
||||
expect(warnings['custom.0.importName']).toStrictEqual({ kind: 'invalid-name' });
|
||||
});
|
||||
|
||||
it('warns when a custom header resolves to an existing Ontime field name', () => {
|
||||
it('warns when a custom header resolves to a built-in or duplicate imported field name', () => {
|
||||
const values = createDefaultFormValues();
|
||||
values.custom = [
|
||||
{ ontimeName: '', importName: 'Artist-1' },
|
||||
@@ -67,12 +67,20 @@ describe('getImportWarnings()', () => {
|
||||
{ ontimeName: '', importName: 'Presenter-1' },
|
||||
];
|
||||
|
||||
const warnings = getImportWarnings(values, ['Artist-1', 'Artist/1', 'Title!', 'Presenter-1'], ['Presenter 1']);
|
||||
const warnings = getImportWarnings(values, ['Artist-1', 'Artist/1', 'Title!', 'Presenter-1']);
|
||||
|
||||
expect(warnings['custom.0.importName']).toBeUndefined();
|
||||
expect(warnings['custom.1.importName']).toStrictEqual({ kind: 'name-collision' });
|
||||
expect(warnings['custom.2.importName']).toStrictEqual({ kind: 'name-collision' });
|
||||
expect(warnings['custom.3.importName']).toStrictEqual({ kind: 'name-collision' });
|
||||
expect(warnings['custom.3.importName']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not give false warnings for pre-existing custom fields', () => {
|
||||
const values = createDefaultFormValues();
|
||||
values.custom = [{ ontimeName: '', importName: 'Video Info' }];
|
||||
|
||||
const warnings = getImportWarnings(values, ['Video Info']);
|
||||
expect(warnings['custom.0.importName']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+6
-12
@@ -127,26 +127,26 @@ function isPersistedFormValues(obj: unknown): obj is ImportFormValues {
|
||||
);
|
||||
}
|
||||
|
||||
export function getPersistedImportState(sourceKey: string): ImportFormValues {
|
||||
export function getPersistedImportState(sourceKey: string): { values: ImportFormValues; isPersisted: boolean } {
|
||||
const storageKey = getImportMapKey(sourceKey);
|
||||
try {
|
||||
const raw = localStorage.getItem(storageKey);
|
||||
if (!raw) {
|
||||
return createDefaultFormValues();
|
||||
return { values: createDefaultFormValues(), isPersisted: false };
|
||||
}
|
||||
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (isPersistedFormValues(parsed)) {
|
||||
return parsed;
|
||||
return { values: parsed, isPersisted: true };
|
||||
}
|
||||
|
||||
// Invalid schema - delete malformed data
|
||||
localStorage.removeItem(storageKey);
|
||||
return createDefaultFormValues();
|
||||
return { values: createDefaultFormValues(), isPersisted: false };
|
||||
} catch {
|
||||
// Parse error - delete corrupted data
|
||||
localStorage.removeItem(storageKey);
|
||||
return createDefaultFormValues();
|
||||
return { values: createDefaultFormValues(), isPersisted: false };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,11 +156,9 @@ export function getPersistedImportState(sourceKey: string): ImportFormValues {
|
||||
export function getImportWarnings(
|
||||
values: ImportFormValues,
|
||||
detectedSpreadsheetColumns: string[],
|
||||
existingCustomFieldLabels: string[] = [],
|
||||
): Record<string, MappingWarning | undefined> {
|
||||
const normalisedHeaders = new Set(detectedSpreadsheetColumns.map(normaliseColumnName).filter(Boolean));
|
||||
const builtInLabels = new Set(builtInFieldDefs.map((def) => def.label.toLowerCase()));
|
||||
const existingLabels = new Set(existingCustomFieldLabels.map((label) => label.trim().toLowerCase()).filter(Boolean));
|
||||
const seenColumns = new Set<string>();
|
||||
const seenDerivedLabels = new Set<string>();
|
||||
const warnings: Record<string, MappingWarning | undefined> = {};
|
||||
@@ -200,11 +198,7 @@ export function getImportWarnings(
|
||||
warnings[key] = { kind: 'missing' };
|
||||
} else {
|
||||
const normalisedDerivedLabel = sanitisedLabel.toLowerCase();
|
||||
if (
|
||||
builtInLabels.has(normalisedDerivedLabel) ||
|
||||
existingLabels.has(normalisedDerivedLabel) ||
|
||||
seenDerivedLabels.has(normalisedDerivedLabel)
|
||||
) {
|
||||
if (builtInLabels.has(normalisedDerivedLabel) || seenDerivedLabels.has(normalisedDerivedLabel)) {
|
||||
warnings[key] = { kind: 'name-collision' };
|
||||
}
|
||||
}
|
||||
|
||||
+22
-11
@@ -5,7 +5,6 @@ import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react';
|
||||
import { useFieldArray, useForm } from 'react-hook-form';
|
||||
|
||||
import { maybeAxiosError } from '../../../../../../common/api/utils';
|
||||
import useCustomFields from '../../../../../../common/hooks-query/useCustomFields';
|
||||
import { formatDuration } from '../../../../../../common/utils/time';
|
||||
import {
|
||||
type ImportFormValues,
|
||||
@@ -118,10 +117,15 @@ export function useSheetImportForm({
|
||||
onApply,
|
||||
onExport,
|
||||
}: UseSheetImportFormProps) {
|
||||
const initialFormValues = useMemo(() => {
|
||||
const persisted = getPersistedImportState(sourceKey);
|
||||
const worksheet = getPreferredWorksheet(worksheetNames, persisted.worksheet, initialMetadata?.worksheet ?? '');
|
||||
return { ...persisted, worksheet };
|
||||
const { initialFormValues, skipAutoPreview } = useMemo(() => {
|
||||
const { values, isPersisted } = getPersistedImportState(sourceKey);
|
||||
const worksheet = getPreferredWorksheet(worksheetNames, values.worksheet, initialMetadata?.worksheet ?? '');
|
||||
// Skip auto-preview when restoring a persisted mapping that was saved for a different worksheet.
|
||||
const hasWorksheetChanged = values.worksheet !== worksheet;
|
||||
return {
|
||||
initialFormValues: { ...values, worksheet },
|
||||
skipAutoPreview: isPersisted && hasWorksheetChanged,
|
||||
};
|
||||
}, [initialMetadata?.worksheet, sourceKey, worksheetNames]);
|
||||
|
||||
const {
|
||||
@@ -139,7 +143,6 @@ export function useSheetImportForm({
|
||||
|
||||
const { fields, append, remove } = useFieldArray({ control, name: 'custom' });
|
||||
const values = watch();
|
||||
const { data: existingCustomFields } = useCustomFields();
|
||||
|
||||
// --- Worksheet metadata via react-query ---
|
||||
const queryClient = useQueryClient();
|
||||
@@ -167,16 +170,14 @@ export function useSheetImportForm({
|
||||
const columnLabels = buildColumnLabels(values);
|
||||
|
||||
const [state, dispatch] = useReducer(importReducer, initialImportState);
|
||||
const existingCustomFieldLabels = useMemo(
|
||||
() => Object.values(existingCustomFields).map((field) => field.label),
|
||||
[existingCustomFields],
|
||||
);
|
||||
const warnings = getImportWarnings(values, headers, existingCustomFieldLabels);
|
||||
const warnings = getImportWarnings(values, headers);
|
||||
const warningCount = Object.values(warnings).filter(Boolean).length;
|
||||
const previewRef = useRef<SpreadsheetPreviewResponse | null>(null);
|
||||
const autoPreviewFiredRef = useRef(false);
|
||||
|
||||
// Rehydrate the form from persisted/default state whenever the source context changes.
|
||||
useEffect(() => {
|
||||
autoPreviewFiredRef.current = false;
|
||||
reset(initialFormValues);
|
||||
dispatch({ type: 'reset' });
|
||||
}, [initialFormValues, reset]);
|
||||
@@ -225,6 +226,16 @@ export function useSheetImportForm({
|
||||
[previewImport],
|
||||
);
|
||||
|
||||
// Auto-preview on mount once metadata is ready.
|
||||
useEffect(() => {
|
||||
if (autoPreviewFiredRef.current) return;
|
||||
if (skipAutoPreview) return;
|
||||
if (!isValid || headers.length === 0) return;
|
||||
|
||||
autoPreviewFiredRef.current = true;
|
||||
handleSubmit(handlePreview)();
|
||||
}, [skipAutoPreview, isValid, headers, handleSubmit, handlePreview]);
|
||||
|
||||
const handleApply = useCallback(async () => {
|
||||
if (!state.preview) return;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { Suspense, useState } from 'react';
|
||||
import { IoArrowDown, IoArrowUp } from 'react-icons/io5';
|
||||
|
||||
import Info from '../../../../common/components/info/Info';
|
||||
@@ -11,11 +11,25 @@ import style from './ProjectPanel.module.scss';
|
||||
type SortParameter = 'alphabetical' | 'modified';
|
||||
|
||||
export default function ProjectList() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className={style.empty}>
|
||||
<Panel.Loader isLoading />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ProjectListSuspend />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectListSuspend() {
|
||||
const [editingMode, setEditingMode] = useState<EditMode | null>(null);
|
||||
const [editingFilename, setEditingFilename] = useState<string | null>(null);
|
||||
const [sortMode, setSortMode] = useState<ProjectSortMode>('modified-desc');
|
||||
|
||||
const { data, refetch, status } = useOrderedProjectList(sortMode);
|
||||
const { data, refetch } = useOrderedProjectList(sortMode);
|
||||
|
||||
const handleToggleEditMode = (editMode: EditMode, filename: string | null) => {
|
||||
setEditingMode((prev) => (prev === editMode && filename === editingFilename ? null : editMode));
|
||||
@@ -38,14 +52,6 @@ export default function ProjectList() {
|
||||
});
|
||||
};
|
||||
|
||||
if (status === 'pending') {
|
||||
return (
|
||||
<div className={style.empty}>
|
||||
<Panel.Loader isLoading />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const numProjects = data.reorderedProjectFiles.length;
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { generateUrl } from '../../../../common/api/session';
|
||||
import CopyTag from '../../../../common/components/copy-tag/CopyTag';
|
||||
import useInfo from '../../../../common/hooks-query/useInfo';
|
||||
import { isOntimeCloud, serverURL } from '../../../../externals';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
export default function McpSection() {
|
||||
const { data: infoData } = useInfo();
|
||||
const [mcpEndpointUrl, setMcpEndpointUrl] = useState('');
|
||||
|
||||
// generate url
|
||||
useEffect(() => {
|
||||
const baseUrl = (() => {
|
||||
if (isOntimeCloud) return serverURL;
|
||||
|
||||
// for local setups we prefer the localhost IP to avoid remote access
|
||||
if (infoData.networkInterfaces.length > 0) {
|
||||
return `http://${infoData.networkInterfaces[0].address}:${infoData.serverPort}`;
|
||||
}
|
||||
return serverURL;
|
||||
})();
|
||||
|
||||
// we are reusing the endpoint, so locking config and nav have no effect
|
||||
generateUrl({ baseUrl, path: 'mcp', authenticate: true, lockConfig: false, lockNav: false })
|
||||
.then(setMcpEndpointUrl)
|
||||
.catch(() => {
|
||||
setMcpEndpointUrl('');
|
||||
});
|
||||
}, [infoData]);
|
||||
|
||||
const mcpClientConfig = mcpEndpointUrl
|
||||
? JSON.stringify({ mcpServers: { ontime: { url: mcpEndpointUrl } } }, null, 2)
|
||||
: '';
|
||||
|
||||
return (
|
||||
<Panel.Section>
|
||||
<Panel.Card>
|
||||
<Panel.SubHeader>MCP Server</Panel.SubHeader>
|
||||
<Panel.Paragraph>Connect any MCP-compatible AI agent to Ontime using the endpoint below.</Panel.Paragraph>
|
||||
<Panel.Divider />
|
||||
<Panel.Field title='Endpoint URL' description='Add this URL to your MCP client settings' />
|
||||
{mcpEndpointUrl && <CopyTag copyValue={mcpEndpointUrl}>{mcpEndpointUrl}</CopyTag>}
|
||||
<Panel.Divider />
|
||||
<Panel.Field
|
||||
title='Client configuration snippet'
|
||||
description='Paste this into your AI agent settings under "mcpServers"'
|
||||
/>
|
||||
{mcpEndpointUrl && <CopyTag copyValue={mcpClientConfig}>{mcpClientConfig}</CopyTag>}
|
||||
</Panel.Card>
|
||||
</Panel.Section>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import type { PanelBaseProps } from '../../panel-list/PanelList';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
import CustomViews from '../manage-panel/CustomViews';
|
||||
import GeneralSettings from './GeneralSettings';
|
||||
import McpSection from './McpSection';
|
||||
import ProjectData from './ProjectData';
|
||||
import ServerPortSettings from './ServerPortSettings';
|
||||
import ViewSettings from './ViewSettings';
|
||||
@@ -13,6 +14,7 @@ export default function SettingsPanel({ location }: PanelBaseProps) {
|
||||
const generalRef = useScrollIntoView<HTMLDivElement>('general', location);
|
||||
const viewRef = useScrollIntoView<HTMLDivElement>('view', location);
|
||||
const customViewsRef = useScrollIntoView<HTMLDivElement>('custom-views', location);
|
||||
const mcpRef = useScrollIntoView<HTMLDivElement>('mcp', location);
|
||||
const portRef = useScrollIntoView<HTMLDivElement>('port', location);
|
||||
|
||||
return (
|
||||
@@ -30,6 +32,9 @@ export default function SettingsPanel({ location }: PanelBaseProps) {
|
||||
<div ref={customViewsRef}>
|
||||
<CustomViews />
|
||||
</div>
|
||||
<div ref={mcpRef}>
|
||||
<McpSection />
|
||||
</div>
|
||||
{!isDocker && (
|
||||
<div ref={portRef}>
|
||||
<ServerPortSettings />
|
||||
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
.footer {
|
||||
width: 100%;
|
||||
}
|
||||
+28
-14
@@ -1,5 +1,5 @@
|
||||
import { TranslationObject, langEn } from 'ontime-types';
|
||||
import { useMemo } from 'react';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
import { maybeAxiosError } from '../../../../../common/api/utils';
|
||||
@@ -10,6 +10,8 @@ import Modal from '../../../../../common/components/modal/Modal';
|
||||
import { useTranslation } from '../../../../../translation/TranslationProvider';
|
||||
import * as Panel from '../../../panel-utils/PanelUtils';
|
||||
|
||||
import style from './CustomTranslationModal.module.scss';
|
||||
|
||||
interface CustomTranslationModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
@@ -17,14 +19,7 @@ interface CustomTranslationModalProps {
|
||||
|
||||
export default function CustomTranslationModal({ isOpen, onClose }: CustomTranslationModalProps) {
|
||||
const { userTranslation, postUserTranslation } = useTranslation();
|
||||
|
||||
const defaultValues = useMemo(() => {
|
||||
const values: Record<string, string> = {};
|
||||
Object.keys(langEn).forEach((key) => {
|
||||
values[toFormKey(key)] = userTranslation[key as keyof TranslationObject] || '';
|
||||
});
|
||||
return values;
|
||||
}, [userTranslation]);
|
||||
const customTranslationFormValues = useMemo(() => toFormValues(userTranslation), [userTranslation]);
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
@@ -33,13 +28,17 @@ export default function CustomTranslationModal({ isOpen, onClose }: CustomTransl
|
||||
formState: { isSubmitting, isDirty, errors, isValid },
|
||||
setError,
|
||||
} = useForm({
|
||||
defaultValues,
|
||||
resetOptions: {
|
||||
keepDirtyValues: true,
|
||||
},
|
||||
defaultValues: customTranslationFormValues,
|
||||
mode: 'onChange',
|
||||
});
|
||||
|
||||
// keep data up to date
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
reset(customTranslationFormValues);
|
||||
}
|
||||
}, [customTranslationFormValues, isOpen, reset]);
|
||||
|
||||
const onSubmit = async (formData: Record<string, string>) => {
|
||||
try {
|
||||
const translationData: Record<string, string> = {};
|
||||
@@ -54,6 +53,10 @@ export default function CustomTranslationModal({ isOpen, onClose }: CustomTransl
|
||||
}
|
||||
};
|
||||
|
||||
const resetToEnglish = () => {
|
||||
reset(toFormValues(langEn), { keepDefaultValues: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title='Edit custom translations'
|
||||
@@ -84,9 +87,12 @@ export default function CustomTranslationModal({ isOpen, onClose }: CustomTransl
|
||||
</Panel.Section>
|
||||
}
|
||||
footerElements={
|
||||
<div>
|
||||
<div className={style.footer}>
|
||||
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}
|
||||
<Panel.InlineElements align='apart'>
|
||||
<Button variant='ghosted' size='large' onClick={resetToEnglish} disabled={isSubmitting}>
|
||||
Reset to English
|
||||
</Button>
|
||||
<Panel.InlineElements>
|
||||
<Button size='large' onClick={onClose}>
|
||||
Cancel
|
||||
@@ -109,6 +115,14 @@ export default function CustomTranslationModal({ isOpen, onClose }: CustomTransl
|
||||
);
|
||||
}
|
||||
|
||||
function toFormValues(translation: Partial<TranslationObject>) {
|
||||
const values: Record<string, string> = {};
|
||||
Object.keys(langEn).forEach((key) => {
|
||||
values[toFormKey(key)] = translation[key as keyof TranslationObject] || '';
|
||||
});
|
||||
return values;
|
||||
}
|
||||
|
||||
function toFormKey(key: string) {
|
||||
return key.replace('.', '_');
|
||||
}
|
||||
|
||||
+1
@@ -20,4 +20,5 @@
|
||||
border: 1px solid $gray-1100;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ export type SettingsOption = {
|
||||
id: string;
|
||||
label: string;
|
||||
secondary?: Readonly<SettingsOption[]>;
|
||||
split?: boolean;
|
||||
highlight?: string;
|
||||
};
|
||||
|
||||
@@ -20,13 +19,13 @@ const staticOptions = [
|
||||
{ id: 'settings__general', label: 'General settings' },
|
||||
{ id: 'settings__view', label: 'View settings' },
|
||||
{ id: 'settings__custom-views', label: 'Custom views' },
|
||||
{ id: 'settings__port', label: 'Server port' },
|
||||
{ id: 'settings__mcp', label: 'MCP Server' },
|
||||
...(isDocker ? [] : [{ id: 'settings__port', label: 'Server port' }]),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'project',
|
||||
label: 'Project',
|
||||
split: true,
|
||||
secondary: [
|
||||
{ id: 'project__create', label: 'Create...' },
|
||||
{ id: 'project__list', label: 'Manage projects' },
|
||||
@@ -46,7 +45,6 @@ const staticOptions = [
|
||||
{
|
||||
id: 'automation',
|
||||
label: 'Automation',
|
||||
split: true,
|
||||
secondary: [
|
||||
{ id: 'automation__settings', label: 'Automation settings' },
|
||||
{ id: 'automation__automations', label: 'Manage automations' },
|
||||
@@ -56,7 +54,6 @@ const staticOptions = [
|
||||
{
|
||||
id: 'sharing',
|
||||
label: 'Sharing and reporting',
|
||||
split: true,
|
||||
secondary: [
|
||||
{ id: 'sharing__presets', label: 'URL Presets' },
|
||||
{
|
||||
@@ -69,7 +66,6 @@ const staticOptions = [
|
||||
{
|
||||
id: 'network',
|
||||
label: 'Network',
|
||||
split: true,
|
||||
secondary: [
|
||||
{
|
||||
id: 'network__log',
|
||||
@@ -84,12 +80,10 @@ const staticOptions = [
|
||||
{
|
||||
id: 'about',
|
||||
label: 'About',
|
||||
split: true,
|
||||
},
|
||||
{
|
||||
id: 'shutdown',
|
||||
label: 'Shutdown',
|
||||
split: true,
|
||||
},
|
||||
] as const;
|
||||
|
||||
@@ -103,18 +97,12 @@ export function useAppSettingsMenu() {
|
||||
|
||||
const options: Readonly<SettingsOption[]> = useMemo(
|
||||
() =>
|
||||
staticOptions.map((option) => ({
|
||||
...option,
|
||||
// if we are in docker don't show the port option
|
||||
secondary:
|
||||
'secondary' in option
|
||||
? isDocker && option.id === 'settings'
|
||||
? [...option.secondary.filter(({ id }) => id !== 'settings__port')]
|
||||
: [...option.secondary]
|
||||
: undefined,
|
||||
staticOptions.map((option) =>
|
||||
// if there is an update then highlight the about setting
|
||||
highlight: option.id === 'about' && data.hasUpdates ? 'New version available' : undefined,
|
||||
})),
|
||||
option.id === 'about' && data.hasUpdates
|
||||
? Object.assign({}, option, { highlight: 'New version available' })
|
||||
: option,
|
||||
),
|
||||
[data],
|
||||
);
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { ErrorBoundary } from '@sentry/react';
|
||||
import { PropsWithChildren, ReactNode } from 'react';
|
||||
import { PropsWithChildren, ReactNode, Suspense } from 'react';
|
||||
|
||||
import ScrollArea from '../../common/components/scroll-area/ScrollArea';
|
||||
import { useIsOnline } from '../../common/hooks/useSocket';
|
||||
import { cx } from '../../common/utils/styleUtils';
|
||||
import Loader from '../../views/common/loader/Loader';
|
||||
|
||||
import style from './Overview.module.scss';
|
||||
|
||||
@@ -15,18 +16,37 @@ export function OverviewWrapper({ navElements, children }: PropsWithChildren<Ove
|
||||
const isOnline = useIsOnline();
|
||||
|
||||
return (
|
||||
<div className={cx([style.overview, !isOnline && style.isOffline])}>
|
||||
<ErrorBoundary>
|
||||
<div className={style.nav}>{navElements}</div>
|
||||
<ScrollArea
|
||||
className={style.infoScroll}
|
||||
contentClassName={style.info}
|
||||
contentStyle={{ minWidth: '100%' }}
|
||||
orientation='horizontal'
|
||||
>
|
||||
{children}
|
||||
</ScrollArea>
|
||||
</ErrorBoundary>
|
||||
<Suspense fallback={<OverviewFallback navElements={navElements} />}>
|
||||
<div className={cx([style.overview, !isOnline && style.isOffline])}>
|
||||
<ErrorBoundary>
|
||||
<div className={style.nav}>{navElements}</div>
|
||||
<ScrollArea
|
||||
className={style.infoScroll}
|
||||
contentClassName={style.info}
|
||||
contentStyle={{ minWidth: '100%' }}
|
||||
orientation='horizontal'
|
||||
>
|
||||
{children}
|
||||
</ScrollArea>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function OverviewFallback({ navElements }: OverviewWrapperProps) {
|
||||
return (
|
||||
<div className={style.overview}>
|
||||
<div className={style.nav}>{navElements}</div>
|
||||
<ScrollArea
|
||||
className={style.infoScroll}
|
||||
contentClassName={style.info}
|
||||
contentStyle={{ minWidth: '100%' }}
|
||||
orientation='horizontal'
|
||||
>
|
||||
{/* TODO: this could be alined in a nicer way */}
|
||||
<Loader />
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary'
|
||||
import ViewNavigationMenu from '../../common/components/navigation-menu/ViewNavigationMenu';
|
||||
import ProtectRoute from '../../common/components/protect-route/ProtectRoute';
|
||||
import { EntryActionsProvider } from '../../common/context/EntryActionsContext';
|
||||
import { useLoadedRundownSource } from '../../common/hooks-query/useScopedRundown';
|
||||
import { useEntryActions } from '../../common/hooks/useEntryAction';
|
||||
import { useIsSmallDevice } from '../../common/hooks/useIsSmallDevice';
|
||||
import { handleLinks } from '../../common/utils/linkUtils';
|
||||
@@ -16,6 +17,7 @@ import EntryEditModal from '../../views/cuesheet/cuesheet-edit-modal/EntryEditMo
|
||||
import { EditorLayoutMode, useEditorLayout } from '../../views/editor/useEditorLayout';
|
||||
import RundownEntryEditor from './entry-editor/RundownEntryEditor';
|
||||
import FinderPlacement from './placements/FinderPlacement';
|
||||
import RenumberCuesDialog from './renumber-cues-dialog/RenumberCuesDialog';
|
||||
import { RundownContextMenu } from './rundown-context-menu/RundownContextMenu';
|
||||
import RundownHeader from './rundown-header/RundownHeader';
|
||||
import RundownHeaderMobile from './rundown-header/RundownHeaderMobile';
|
||||
@@ -103,6 +105,8 @@ interface RundownRootProps {
|
||||
}
|
||||
|
||||
function RundownRoot({ isSmallDevice, isExtracted, viewMode, setViewMode }: RundownRootProps) {
|
||||
const source = useLoadedRundownSource();
|
||||
|
||||
return (
|
||||
<div className={style.rundownRoot}>
|
||||
{isSmallDevice ? (
|
||||
@@ -111,7 +115,8 @@ function RundownRoot({ isSmallDevice, isExtracted, viewMode, setViewMode }: Rund
|
||||
<RundownHeader isExtracted={isExtracted} viewMode={viewMode} setViewMode={setViewMode} />
|
||||
)}
|
||||
{viewMode === RundownViewMode.List ? <RundownList /> : <RundownTable />}
|
||||
{viewMode === RundownViewMode.Table && <EntryEditModal />}
|
||||
{viewMode === RundownViewMode.Table && <EntryEditModal rundown={source.rundown} />}
|
||||
<RenumberCuesDialog />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { OntimeEntry, isOntimeEvent, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
|
||||
import { OntimeEntry, Rundown, isOntimeEvent, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import useRundown from '../../../common/hooks-query/useRundown';
|
||||
import EventEditor from './EventEditor';
|
||||
import GroupEditor from './GroupEditor';
|
||||
import MilestoneEditor from './MilestoneEditor';
|
||||
@@ -10,19 +9,18 @@ import style from './EntryEditor.module.scss';
|
||||
|
||||
interface CuesheetEntryEditorProps {
|
||||
entryId: string;
|
||||
rundown: Rundown;
|
||||
}
|
||||
|
||||
export default function CuesheetEntryEditor({ entryId }: CuesheetEntryEditorProps) {
|
||||
const { data } = useRundown();
|
||||
|
||||
export default function CuesheetEntryEditor({ entryId, rundown }: CuesheetEntryEditorProps) {
|
||||
const entry = useMemo<OntimeEntry | null>(() => {
|
||||
if (data.order.length === 0) {
|
||||
if (rundown.order.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const event = data.entries[entryId];
|
||||
const event = rundown.entries[entryId];
|
||||
return event ?? null;
|
||||
}, [entryId, data.order, data.entries]);
|
||||
}, [entryId, rundown.entries, rundown.order.length]);
|
||||
|
||||
if (isOntimeEvent(entry)) {
|
||||
return (
|
||||
|
||||
@@ -104,6 +104,7 @@
|
||||
|
||||
/* approximating the style of a disabled input */
|
||||
.textLikeInput {
|
||||
color: $gray-200;
|
||||
background: transparent;
|
||||
justify-content: start;
|
||||
width: 7.5em;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { sanitiseCue } from 'ontime-utils';
|
||||
import { memo } from 'react';
|
||||
|
||||
import * as Editor from '../../../../common/components/editor-utils/EditorUtils';
|
||||
@@ -24,10 +23,6 @@ export default memo(EventEditorTitles);
|
||||
function EventEditorTitles({ eventId, cue, flag, title, note, colour }: EventEditorTitlesProps) {
|
||||
const { updateEntry } = useEntryActionsContext();
|
||||
|
||||
const cueSubmitHandler = (_field: string, newValue: string) => {
|
||||
updateEntry({ id: eventId, cue: sanitiseCue(newValue) });
|
||||
};
|
||||
|
||||
const flagSubmitHandler = (newValue: boolean) => {
|
||||
updateEntry({ id: eventId, flag: newValue });
|
||||
};
|
||||
@@ -48,7 +43,7 @@ function EventEditorTitles({ eventId, cue, flag, title, note, colour }: EventEdi
|
||||
field='cue'
|
||||
label='Cue'
|
||||
initialValue={cue}
|
||||
submitHandler={cueSubmitHandler}
|
||||
submitHandler={textSubmitHandler}
|
||||
maxLength={10}
|
||||
/>
|
||||
<div>
|
||||
|
||||
+16
-58
@@ -4,18 +4,6 @@
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.section,
|
||||
.formSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: $aux-text-size;
|
||||
color: $label-gray;
|
||||
}
|
||||
|
||||
.triggerList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -25,25 +13,13 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.triggerForm {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.formFields {
|
||||
.triggerHeader {
|
||||
display: grid;
|
||||
grid-template-columns: 8rem 1fr;
|
||||
gap: 0.75rem;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.formActions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
min-height: 2rem;
|
||||
grid-template-columns: 8rem 1fr 2rem;
|
||||
gap: 0.5rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: $aux-text-size;
|
||||
color: $label-gray;
|
||||
}
|
||||
|
||||
.trigger {
|
||||
@@ -51,40 +27,22 @@
|
||||
display: grid;
|
||||
grid-template-columns: 8rem 1fr 2rem;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
min-height: 2.5rem;
|
||||
|
||||
&:not(:first-child) {
|
||||
border-top: 1px solid $white-10;
|
||||
}
|
||||
|
||||
&[data-duplicate] {
|
||||
> button:not(:last-child) {
|
||||
border-color: $orange-500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.triggerMeta {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.metaLabel {
|
||||
color: $label-gray;
|
||||
.duplicateMessage {
|
||||
padding-left: 0.75rem;
|
||||
font-size: $aux-text-size;
|
||||
line-height: 1.2;
|
||||
margin-bottom: 0.125rem;
|
||||
}
|
||||
|
||||
.automationTitle {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.validationError,
|
||||
.validationSuccess {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
font-size: $aux-text-size;
|
||||
}
|
||||
|
||||
.validationError {
|
||||
color: $red-500;
|
||||
}
|
||||
|
||||
.validationSuccess {
|
||||
color: $green-500;
|
||||
color: $orange-500;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { NormalisedAutomation, TimerLifeCycle, Trigger, timerLifecycleValues } from 'ontime-types';
|
||||
import { Trigger } from 'ontime-types';
|
||||
import { generateId } from 'ontime-utils';
|
||||
import { Fragment, useCallback, useMemo, useState } from 'react';
|
||||
import { IoAlertCircle, IoCheckmarkCircle, IoTrash } from 'react-icons/io5';
|
||||
import { IoAdd, IoTrash } from 'react-icons/io5';
|
||||
|
||||
import Button from '../../../../common/components/buttons/Button';
|
||||
import IconButton from '../../../../common/components/buttons/IconButton';
|
||||
import * as Editor from '../../../../common/components/editor-utils/EditorUtils';
|
||||
import Info from '../../../../common/components/info/Info';
|
||||
import Select from '../../../../common/components/select/Select';
|
||||
import { useEntryActionsContext } from '../../../../common/context/EntryActionsContext';
|
||||
@@ -21,174 +19,108 @@ interface EventEditorTriggersProps {
|
||||
|
||||
export default function EventEditorTriggers({ triggers, eventId }: EventEditorTriggersProps) {
|
||||
const { data: automationSettings, status: automationStatus } = useAutomationSettings();
|
||||
const { updateEntry } = useEntryActionsContext();
|
||||
const automationsEnabled = automationStatus === 'pending' ? undefined : automationSettings.enabledAutomations;
|
||||
const showTriggers = triggers.length > 0;
|
||||
|
||||
const allAutomationOptions = Object.values(automationSettings.automations).map(({ id, title }) => ({
|
||||
value: id,
|
||||
label: title,
|
||||
}));
|
||||
const hasAutomationOptions = allAutomationOptions.length > 0;
|
||||
const triggerOptions = eventTriggerOptions.map((cycle) => ({ value: cycle, label: cycle }));
|
||||
|
||||
const duplicateIds = new Set<string>();
|
||||
const seen = new Map<string, string>();
|
||||
for (const trigger of triggers) {
|
||||
const key = `${trigger.trigger}:${trigger.automationId}`;
|
||||
if (seen.has(key)) {
|
||||
duplicateIds.add(trigger.id);
|
||||
} else {
|
||||
seen.set(key, trigger.id);
|
||||
}
|
||||
}
|
||||
|
||||
const handleAdd = () => {
|
||||
if (!hasAutomationOptions) return;
|
||||
updateEntry({
|
||||
id: eventId,
|
||||
triggers: [
|
||||
...triggers,
|
||||
{ id: generateId(), title: '', trigger: eventTriggerOptions[0], automationId: allAutomationOptions[0].value },
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const handleDelete = (triggerId: string) => {
|
||||
updateEntry({ id: eventId, triggers: triggers.filter((t) => t.id !== triggerId) });
|
||||
};
|
||||
|
||||
const handleChange = (triggerId: string, field: 'trigger' | 'automationId', value: string) => {
|
||||
const newTriggers = triggers.map((t) => (t.id !== triggerId ? t : { ...t, [field]: value }));
|
||||
updateEntry({ id: eventId, triggers: newTriggers });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={style.triggers}>
|
||||
{automationsEnabled === false && (
|
||||
<Info>Automations are disabled. Event triggers stay configured, but they will not run until enabled.</Info>
|
||||
)}
|
||||
{showTriggers && (
|
||||
<div className={style.section}>
|
||||
<div className={style.sectionTitle}>Applied automations</div>
|
||||
<ExistingEventTriggers triggers={triggers} eventId={eventId} automations={automationSettings.automations} />
|
||||
</div>
|
||||
{automationsEnabled && !hasAutomationOptions && (
|
||||
<Info>
|
||||
<Info.Title>No automations available</Info.Title>
|
||||
<Info.Body>Create an automation before attaching it to this event.</Info.Body>
|
||||
</Info>
|
||||
)}
|
||||
<Editor.Panel className={style.formSection}>
|
||||
<div className={style.sectionTitle}>Add automation</div>
|
||||
<EventTriggerForm triggers={triggers} eventId={eventId} automations={automationSettings.automations} />
|
||||
</Editor.Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface EventTriggerFormProps {
|
||||
eventId: string;
|
||||
triggers?: Trigger[];
|
||||
automations: NormalisedAutomation;
|
||||
}
|
||||
|
||||
function EventTriggerForm({ eventId, triggers, automations }: EventTriggerFormProps) {
|
||||
const { updateEntry } = useEntryActionsContext();
|
||||
const [automationId, setAutomationId] = useState<string | undefined>(undefined);
|
||||
const [cycleValue, setCycleValue] = useState(TimerLifeCycle.onStart);
|
||||
|
||||
const handleSubmit = (triggerLifeCycle: TimerLifeCycle, automationId: string) => {
|
||||
const newTriggers = triggers ?? new Array<Trigger>();
|
||||
const id = generateId();
|
||||
newTriggers.push({ id, title: '', trigger: triggerLifeCycle, automationId });
|
||||
updateEntry({ id: eventId, triggers: newTriggers });
|
||||
};
|
||||
|
||||
const getValidationError = (cycle: TimerLifeCycle, automationId?: string): string | undefined => {
|
||||
if (automationId === undefined) {
|
||||
return 'Select an automation';
|
||||
}
|
||||
if (!Object.keys(automations).includes(automationId)) {
|
||||
return 'This automation does not exist';
|
||||
}
|
||||
if (triggers === undefined) {
|
||||
return;
|
||||
}
|
||||
return Object.values(triggers).some((t) => t.automationId === automationId && t.trigger === cycle)
|
||||
? 'Automation can only be used once'
|
||||
: undefined;
|
||||
};
|
||||
|
||||
const validationError = getValidationError(cycleValue, automationId);
|
||||
const validationLabel = validationError ?? 'Ready to add automation';
|
||||
|
||||
const triggerOptions = useMemo(
|
||||
() => [
|
||||
{ value: null, label: 'Select lifecycle' },
|
||||
...eventTriggerOptions.map((cycle) => ({ value: cycle, label: cycle })),
|
||||
],
|
||||
[], // eventTriggerOptions is a constant, no need for dependency
|
||||
);
|
||||
|
||||
const automationOptions = useMemo(
|
||||
() => [
|
||||
{ value: null, label: 'Select Automation' },
|
||||
...Object.values(automations).map(({ id, title }) => ({ value: id, label: title })),
|
||||
],
|
||||
[automations], // This needs to be a dependency as it can change
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={style.triggerForm}>
|
||||
<div className={style.formFields}>
|
||||
<div>
|
||||
<Editor.Label>Lifecycle</Editor.Label>
|
||||
<Select
|
||||
value={cycleValue}
|
||||
onValueChange={(value) => {
|
||||
if (value !== null) setCycleValue(value);
|
||||
}}
|
||||
options={triggerOptions}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Editor.Label>Automation</Editor.Label>
|
||||
<Select
|
||||
value={automationId ?? null}
|
||||
onValueChange={(value) => {
|
||||
if (value !== null) setAutomationId(value);
|
||||
}}
|
||||
options={automationOptions}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={style.formActions}>
|
||||
<div className={validationError ? style.validationError : style.validationSuccess}>
|
||||
{validationError ? <IoAlertCircle /> : <IoCheckmarkCircle />}
|
||||
<span>{validationLabel}</span>
|
||||
</div>
|
||||
<Button
|
||||
disabled={validationError !== undefined}
|
||||
onClick={() => automationId && handleSubmit(cycleValue, automationId)}
|
||||
>
|
||||
Add automation
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ExistingEventTriggersProps {
|
||||
eventId: string;
|
||||
triggers: Trigger[];
|
||||
automations: NormalisedAutomation;
|
||||
}
|
||||
|
||||
function ExistingEventTriggers({ eventId, triggers, automations }: ExistingEventTriggersProps) {
|
||||
const { updateEntry } = useEntryActionsContext();
|
||||
|
||||
const handleDelete = useCallback(
|
||||
(triggerId: string) => {
|
||||
const newTriggers = triggers.filter((trigger) => trigger.id !== triggerId);
|
||||
updateEntry({ id: eventId, triggers: newTriggers });
|
||||
},
|
||||
[eventId, triggers, updateEntry],
|
||||
);
|
||||
|
||||
const filteredTriggers: Record<string, Trigger[]> = {};
|
||||
|
||||
// sort triggers out into groups by the Lifecycle they are on
|
||||
timerLifecycleValues.forEach((triggerType) => {
|
||||
const thisTriggerType = triggers.filter((trigger) => trigger.trigger === triggerType);
|
||||
if (thisTriggerType.length) {
|
||||
Object.assign(filteredTriggers, { [triggerType]: thisTriggerType });
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={style.triggerList}>
|
||||
{Object.entries(filteredTriggers).map(([triggerLifeCycle, triggerGroup]) => (
|
||||
<Fragment key={triggerLifeCycle}>
|
||||
{triggerGroup.map((trigger) => {
|
||||
const { id, automationId } = trigger;
|
||||
const automationTitle = automations[automationId]?.title ?? '<MISSING AUTOMATION>';
|
||||
{triggers.length > 0 && (
|
||||
<div className={style.triggerList}>
|
||||
<div className={style.triggerHeader}>
|
||||
<span>Lifecycle</span>
|
||||
<span>Automation</span>
|
||||
</div>
|
||||
{triggers.map((trigger) => {
|
||||
const isDuplicate = duplicateIds.has(trigger.id);
|
||||
const lifecycleOptions = isDuplicate
|
||||
? triggerOptions.map((opt) => (opt.value === trigger.trigger ? { ...opt, label: `${opt.label} *` } : opt))
|
||||
: triggerOptions;
|
||||
const automationOptions = isDuplicate
|
||||
? allAutomationOptions.map((opt) =>
|
||||
opt.value === trigger.automationId ? { ...opt, label: `${opt.label} *` } : opt,
|
||||
)
|
||||
: allAutomationOptions;
|
||||
return (
|
||||
<div key={id} className={style.trigger}>
|
||||
<div className={style.triggerMeta}>
|
||||
<div className={style.metaLabel}>Lifecycle</div>
|
||||
<div>{triggerLifeCycle}</div>
|
||||
</div>
|
||||
<div className={style.triggerMeta}>
|
||||
<div className={style.metaLabel}>Automation</div>
|
||||
<div className={style.automationTitle}>{automationTitle}</div>
|
||||
</div>
|
||||
<IconButton variant='ghosted-destructive' onClick={() => handleDelete(id)}>
|
||||
<div key={trigger.id} className={style.trigger} data-duplicate={isDuplicate || undefined}>
|
||||
<Select
|
||||
value={trigger.trigger}
|
||||
onValueChange={(value) => {
|
||||
if (value !== null) handleChange(trigger.id, 'trigger', value);
|
||||
}}
|
||||
options={lifecycleOptions}
|
||||
/>
|
||||
<Select
|
||||
value={trigger.automationId}
|
||||
onValueChange={(value) => {
|
||||
if (value !== null) handleChange(trigger.id, 'automationId', value);
|
||||
}}
|
||||
options={automationOptions}
|
||||
/>
|
||||
<IconButton variant='ghosted-destructive' onClick={() => handleDelete(trigger.id)}>
|
||||
<IoTrash />
|
||||
</IconButton>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{duplicateIds.size > 0 && (
|
||||
<span className={style.duplicateMessage}>
|
||||
* Duplicate combinations will only fire once per lifecycle event.
|
||||
</span>
|
||||
)}
|
||||
{hasAutomationOptions && (
|
||||
<Button variant='ghosted' onClick={handleAdd}>
|
||||
<IoAdd /> Add automation
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
@use '../../../theme/ontimeColours' as *;
|
||||
@use '../../../theme/ontimeStyles' as *;
|
||||
|
||||
.fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding-inline: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: $inner-section-text-size;
|
||||
color: $label-gray;
|
||||
}
|
||||
|
||||
.error {
|
||||
padding-inline: 0.5rem;
|
||||
font-size: $inner-section-text-size;
|
||||
color: $error-red;
|
||||
margin: 0;
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { RenumberCues } from 'ontime-types';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { create } from 'zustand';
|
||||
|
||||
import { maybeAxiosError } from '../../../common/api/utils';
|
||||
import Button from '../../../common/components/buttons/Button';
|
||||
import Dialog from '../../../common/components/dialog/Dialog';
|
||||
import Input from '../../../common/components/input/input/Input';
|
||||
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
|
||||
import useRundown from '../../../common/hooks-query/useRundown';
|
||||
import { orderEntries } from '../rundown.utils';
|
||||
import { useEventSelection } from '../useEventSelection';
|
||||
|
||||
import style from './RenumberCuesDialog.module.scss';
|
||||
|
||||
type RenumberCueData = Pick<RenumberCues, 'increment' | 'prefix' | 'start'>;
|
||||
|
||||
export default function RenumberCuesDialog() {
|
||||
'use memo';
|
||||
const { data } = useRundown();
|
||||
const { flatOrder } = data;
|
||||
const { onClose, isOpen } = useRenumberCuesDialogStore();
|
||||
const { renumberCues } = useEntryActionsContext();
|
||||
const selectedEvents = useEventSelection((state) => state.selectedEvents);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setError,
|
||||
clearErrors,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<RenumberCueData>();
|
||||
|
||||
const onSubmit = async (data: RenumberCueData) => {
|
||||
clearErrors();
|
||||
try {
|
||||
const { prefix, start, increment } = data;
|
||||
const orderedEvents = orderEntries(Array.from(selectedEvents), flatOrder);
|
||||
await renumberCues(orderedEvents, prefix, start, increment);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
const message = maybeAxiosError(error);
|
||||
setError('root', { message });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title='Renumber cues'
|
||||
showCloseButton
|
||||
showBackdrop
|
||||
bodyElements={
|
||||
<form id='renumber-cues-form' onSubmit={handleSubmit(onSubmit)} className={style.fields}>
|
||||
<div className={style.field}>
|
||||
<label className={style.label}>
|
||||
Prefix
|
||||
<Input
|
||||
{...register('prefix')}
|
||||
type='text'
|
||||
maxLength={8}
|
||||
height='large'
|
||||
fluid
|
||||
autoComplete='off'
|
||||
placeholder='A'
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className={style.field}>
|
||||
<label className={style.label}>
|
||||
Start
|
||||
<Input
|
||||
{...register('start')}
|
||||
type='number'
|
||||
required
|
||||
step={0.001}
|
||||
height='large'
|
||||
fluid
|
||||
autoComplete='off'
|
||||
placeholder='10'
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className={style.field}>
|
||||
<label className={style.label}>
|
||||
Increment
|
||||
<Input
|
||||
{...register('increment')}
|
||||
type='number'
|
||||
required
|
||||
step={0.001}
|
||||
height='large'
|
||||
fluid
|
||||
autoComplete='off'
|
||||
placeholder='0.1'
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{errors.root && <p className={style.error}>{errors.root.message}</p>}
|
||||
</form>
|
||||
}
|
||||
footerElements={
|
||||
<>
|
||||
<Button type='button' variant='subtle-white' onClick={onClose} disabled={isSubmitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type='submit' variant='primary' form='renumber-cues-form' loading={isSubmitting}>
|
||||
Renumber
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface RenumberCuesDialogState {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onOpen: () => void;
|
||||
}
|
||||
|
||||
export const useRenumberCuesDialogStore = create<RenumberCuesDialogState>()((set) => ({
|
||||
isOpen: false,
|
||||
onClose: () => {
|
||||
set({ isOpen: false });
|
||||
},
|
||||
onOpen: () => {
|
||||
set({ isOpen: true });
|
||||
},
|
||||
}));
|
||||
@@ -13,13 +13,14 @@ import {
|
||||
IoTrash,
|
||||
IoUnlink,
|
||||
} from 'react-icons/io5';
|
||||
import { TbFlagFilled } from 'react-icons/tb';
|
||||
import { TbFlagFilled, TbListNumbers } from 'react-icons/tb';
|
||||
|
||||
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
|
||||
import { useContextMenu } from '../../../common/hooks/useContextMenu';
|
||||
import { useEntryCopy } from '../../../common/stores/entryCopyStore';
|
||||
import { deviceMod } from '../../../common/utils/deviceUtils';
|
||||
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
|
||||
import { useRenumberCuesDialogStore } from '../renumber-cues-dialog/RenumberCuesDialog';
|
||||
import { useEventIdSwapping } from '../useEventIdSwapping';
|
||||
import { getSelectionMode, useEventSelection } from '../useEventSelection';
|
||||
import RundownEventInner from './RundownEventInner';
|
||||
@@ -99,6 +100,7 @@ export default function RundownEvent({
|
||||
const selectedEventId = useEventIdSwapping((state) => state.selectedEventId);
|
||||
const setSelectedEventId = useEventIdSwapping((state) => state.setSelectedEventId);
|
||||
const clearSelectedEventId = useEventIdSwapping((state) => state.clearSelectedEventId);
|
||||
const openRenumberDialog = useRenumberCuesDialogStore((state) => state.onOpen);
|
||||
|
||||
const { updateEntry, batchUpdateEvents, clone, deleteEntry, groupEntries, swapEvents } = useEntryActionsContext();
|
||||
|
||||
@@ -143,6 +145,13 @@ export default function RundownEvent({
|
||||
disabled: parent !== null,
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
type: 'item',
|
||||
label: 'Renumber cues',
|
||||
icon: TbListNumbers,
|
||||
onClick: openRenumberDialog,
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
type: 'item',
|
||||
label: 'Delete',
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import { Toolbar } from '@base-ui/react/toolbar';
|
||||
import type { Column } from '@tanstack/react-table';
|
||||
|
||||
import type { ExtendedEntry } from '../../../common/utils/rundownMetadata';
|
||||
import {
|
||||
ColumnSettings,
|
||||
ViewSettings,
|
||||
} from '../../../views/cuesheet/cuesheet-table/cuesheet-table-settings/CuesheetTableSettings';
|
||||
import { usePersistedRundownOptions } from '../rundown.options';
|
||||
|
||||
import style from '../../../views/cuesheet/cuesheet-table/cuesheet-table-settings/CuesheetTableSettings.module.scss';
|
||||
|
||||
interface EditorTableSettingsProps {
|
||||
columns: Column<ExtendedEntry, unknown>[];
|
||||
handleResetResizing: () => void;
|
||||
handleResetReordering: () => void;
|
||||
handleClearToggles: () => void;
|
||||
}
|
||||
|
||||
export default function EditorTableSettings({
|
||||
columns,
|
||||
handleResetResizing,
|
||||
handleResetReordering,
|
||||
handleClearToggles,
|
||||
}: EditorTableSettingsProps) {
|
||||
const options = usePersistedRundownOptions();
|
||||
|
||||
return (
|
||||
<Toolbar.Root className={style.tableSettings}>
|
||||
<ViewSettings optionsStore={options} />
|
||||
<ColumnSettings
|
||||
columns={columns}
|
||||
handleResetResizing={handleResetResizing}
|
||||
handleResetReordering={handleResetReordering}
|
||||
handleClearToggles={handleClearToggles}
|
||||
/>
|
||||
</Toolbar.Root>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import { memo, useEffect, useMemo } from 'react';
|
||||
|
||||
import EmptyPage from '../../../common/components/state/EmptyPage';
|
||||
import { EntryActionsProvider } from '../../../common/context/EntryActionsContext';
|
||||
import useCustomFields from '../../../common/hooks-query/useCustomFields';
|
||||
import { useLoadedRundownSource } from '../../../common/hooks-query/useScopedRundown';
|
||||
import { useEntryActions } from '../../../common/hooks/useEntryAction';
|
||||
import CuesheetDnd from '../../../views/cuesheet/cuesheet-dnd/CuesheetDnd';
|
||||
import CuesheetTable from '../../../views/cuesheet/cuesheet-table/CuesheetTable';
|
||||
import { useCuesheetPermissions } from '../../../views/cuesheet/useTablePermissions';
|
||||
@@ -13,6 +16,8 @@ function RundownTable() {
|
||||
const { data: customFields, status: customFieldStatus } = useCustomFields();
|
||||
const setPermissions = useCuesheetPermissions((state) => state.setPermissions);
|
||||
const { editorMode } = useEditorFollowMode();
|
||||
const source = useLoadedRundownSource();
|
||||
const actions = useEntryActions();
|
||||
|
||||
// Editor always has full permissions
|
||||
useEffect(() => {
|
||||
@@ -30,12 +35,14 @@ function RundownTable() {
|
||||
const isLoading = !customFields || customFieldStatus === 'pending';
|
||||
|
||||
return (
|
||||
<CuesheetDnd columns={columns} tableRoot='editor'>
|
||||
{isLoading ? (
|
||||
<EmptyPage text='Loading...' />
|
||||
) : (
|
||||
<CuesheetTable columns={columns} cuesheetMode={editorMode} tableRoot='editor' />
|
||||
)}
|
||||
</CuesheetDnd>
|
||||
<EntryActionsProvider actions={actions}>
|
||||
<CuesheetDnd columns={columns} tableRoot='editor'>
|
||||
{isLoading ? (
|
||||
<EmptyPage text='Loading...' />
|
||||
) : (
|
||||
<CuesheetTable columns={columns} source={source} cuesheetMode={editorMode} tableRoot='editor' />
|
||||
)}
|
||||
</CuesheetDnd>
|
||||
</EntryActionsProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { EntryId, MaybeNumber, ProjectRundownsList, Rundown, isOntimeEvent } fro
|
||||
import { MouseEvent } from 'react';
|
||||
import { create } from 'zustand';
|
||||
|
||||
import { CURRENT_RUNDOWN_QUERY_KEY, PROJECT_RUNDOWNS, getRundownQueryKey } from '../../common/api/constants';
|
||||
import { PROJECT_RUNDOWNS, getRundownQueryKey } from '../../common/api/constants';
|
||||
import { ontimeQueryClient } from '../../common/queryClient';
|
||||
import { isMacOS } from '../../common/utils/deviceUtils';
|
||||
|
||||
@@ -137,12 +137,9 @@ export const useEventSelection = create<EventSelectionStore>()((set, get) => ({
|
||||
}));
|
||||
|
||||
function getLoadedRundownData() {
|
||||
const loadedRundownId = ontimeQueryClient.getQueryData<ProjectRundownsList>(PROJECT_RUNDOWNS)?.loaded;
|
||||
if (loadedRundownId) {
|
||||
return ontimeQueryClient.getQueryData<Rundown>(getRundownQueryKey(loadedRundownId));
|
||||
}
|
||||
|
||||
return ontimeQueryClient.getQueryData<Rundown>(CURRENT_RUNDOWN_QUERY_KEY);
|
||||
const rundownId = ontimeQueryClient.getQueryData<ProjectRundownsList>(PROJECT_RUNDOWNS)?.loaded;
|
||||
if (!rundownId) return undefined;
|
||||
return ontimeQueryClient.getQueryData<Rundown>(getRundownQueryKey(rundownId));
|
||||
}
|
||||
|
||||
export function getSelectionMode(event: MouseEvent): SelectionMode {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { OntimeView, URLPreset } from 'ontime-types';
|
||||
import { generateId } from 'ontime-utils';
|
||||
import { useRef, useState } from 'react';
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { FieldErrors, useForm } from 'react-hook-form';
|
||||
|
||||
import { generateUrl } from '../../common/api/session';
|
||||
@@ -18,7 +18,7 @@ import { preventEscape } from '../../common/utils/keyEvent';
|
||||
import { isUrlSafe } from '../../common/utils/regex';
|
||||
import { isOntimeCloud, serverURL } from '../../externals';
|
||||
import * as Panel from '../app-settings/panel-utils/PanelUtils';
|
||||
import CuesheetLinkOptions from './composite/CuesheetLinkOptions';
|
||||
import CuesheetLinkOptions, { CuesheetPermissionValues } from './composite/CuesheetLinkOptions';
|
||||
|
||||
import style from './GenerateLinkForm.module.scss';
|
||||
|
||||
@@ -53,12 +53,26 @@ type GenerateLinkState = 'pending' | 'loading' | 'success' | 'error';
|
||||
|
||||
export default function GenerateLinkForm({ hostOptions, pathOptions, presets, isLockedToView }: GenerateLinkFormProps) {
|
||||
const [formState, setFormState] = useState<GenerateLinkState>('pending');
|
||||
const [url, setUrl] = useState(serverURL);
|
||||
const cuesheetReadRef = useRef<HTMLInputElement>(null);
|
||||
const cuesheetWriteRef = useRef<HTMLInputElement>(null);
|
||||
const [url, setUrl] = useState('');
|
||||
const [cuesheetPermissions, setCuesheetPermissions] = useState<CuesheetPermissionValues>({
|
||||
read: 'full',
|
||||
write: 'full',
|
||||
});
|
||||
const generatedAlias = useRef<string>(`cuesheet-${generateId()}`);
|
||||
|
||||
const { addPreset } = useUpdateUrlPreset();
|
||||
const { addPreset, updatePreset } = useUpdateUrlPreset();
|
||||
// Tracks the alias we already created this session so re-generating updates rather than duplicates it
|
||||
const createdAlias = useRef<string | null>(null);
|
||||
|
||||
/**
|
||||
* Permissions live outside react-hook-form, so we reset a successful state manually
|
||||
* whenever they change - this re-arms the "Create share link" button as the previous
|
||||
* link no longer reflects the selected permissions.
|
||||
*/
|
||||
const handlePermissionsChange = useCallback((permissions: CuesheetPermissionValues) => {
|
||||
setCuesheetPermissions(permissions);
|
||||
setFormState((current) => (current === 'success' ? 'pending' : current));
|
||||
}, []);
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
@@ -90,16 +104,20 @@ export default function GenerateLinkForm({ hostOptions, pathOptions, presets, is
|
||||
if (options.read === '-') {
|
||||
throw new Error('Cannot create a share with no read permissions');
|
||||
}
|
||||
const presets = await addPreset({
|
||||
const payload = {
|
||||
target: OntimeView.Cuesheet,
|
||||
enabled: true,
|
||||
alias,
|
||||
search: '',
|
||||
displayInNav: false,
|
||||
options: {
|
||||
read: options.read,
|
||||
write: options.write,
|
||||
},
|
||||
});
|
||||
} as const;
|
||||
// Re-generating with the same name updates the existing preset instead of failing on a duplicate alias
|
||||
const presets = createdAlias.current === alias ? await updatePreset(alias, payload) : await addPreset(payload);
|
||||
createdAlias.current = alias;
|
||||
return presets.find((preset) => preset.alias === alias);
|
||||
};
|
||||
|
||||
@@ -108,8 +126,8 @@ export default function GenerateLinkForm({ hostOptions, pathOptions, presets, is
|
||||
setFormState('loading');
|
||||
if (options.path === OntimeView.Cuesheet) {
|
||||
const urlPreset = await createPresetFromOptions((options as CuesheetLinkOptions).alias, {
|
||||
read: cuesheetReadRef.current?.value ?? 'full',
|
||||
write: cuesheetWriteRef.current?.value ?? 'full',
|
||||
read: cuesheetPermissions.read,
|
||||
write: cuesheetPermissions.write,
|
||||
});
|
||||
|
||||
if (!urlPreset) {
|
||||
@@ -157,6 +175,7 @@ export default function GenerateLinkForm({ hostOptions, pathOptions, presets, is
|
||||
}
|
||||
};
|
||||
|
||||
const noReadAccess = watch('path') === OntimeView.Cuesheet && cuesheetPermissions.read === '-';
|
||||
const canSubmit = isDirty || formState !== 'success';
|
||||
|
||||
return (
|
||||
@@ -220,7 +239,7 @@ export default function GenerateLinkForm({ hostOptions, pathOptions, presets, is
|
||||
})}
|
||||
/>
|
||||
</Panel.ListItem>
|
||||
<CuesheetLinkOptions readRef={cuesheetReadRef} writeRef={cuesheetWriteRef} />
|
||||
<CuesheetLinkOptions onChange={handlePermissionsChange} />
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -268,18 +287,29 @@ export default function GenerateLinkForm({ hostOptions, pathOptions, presets, is
|
||||
</Panel.ListGroup>
|
||||
<Panel.Error>{errors.root?.message}</Panel.Error>
|
||||
<Panel.InlineElements align='end' className={style.end}>
|
||||
<Button type='submit' variant={canSubmit ? 'primary' : 'subtle'} loading={formState === 'loading'}>
|
||||
<Button
|
||||
type='submit'
|
||||
variant={canSubmit ? 'primary' : 'subtle'}
|
||||
loading={formState === 'loading'}
|
||||
disabled={noReadAccess}
|
||||
>
|
||||
{canSubmit ? 'Create share link' : 'Link copied to clipboard!'}
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
</div>
|
||||
<Panel.Section className={style.column}>
|
||||
<Panel.Description>Share this link</Panel.Description>
|
||||
<QRCode size={172} value={url} />
|
||||
<div className={style.copiableLink} data-testid='copy-link'>
|
||||
{url}
|
||||
</div>
|
||||
<CopyTag copyValue={url}>Copy link</CopyTag>
|
||||
{url ? (
|
||||
<>
|
||||
<QRCode size={172} value={url} />
|
||||
<div className={style.copiableLink} data-testid='copy-link'>
|
||||
{url}
|
||||
</div>
|
||||
<CopyTag copyValue={url}>Copy link</CopyTag>
|
||||
</>
|
||||
) : (
|
||||
<Panel.Description>Your link will appear here once you create it.</Panel.Description>
|
||||
)}
|
||||
</Panel.Section>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Fragment, RefObject, useMemo, useState } from 'react';
|
||||
import { Fragment, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import RadioGroup from '../../../common/components/radio-group/RadioGroup';
|
||||
import Switch from '../../../common/components/switch/Switch';
|
||||
@@ -10,118 +10,141 @@ import style from './CuesheetLinkOptions.module.scss';
|
||||
|
||||
type AccessMode = 'full' | 'custom';
|
||||
|
||||
interface CuesheetLinkOptionsProps {
|
||||
readRef?: RefObject<HTMLInputElement | null>;
|
||||
writeRef?: RefObject<HTMLInputElement | null>;
|
||||
export interface CuesheetPermissionValues {
|
||||
read: string;
|
||||
write: string;
|
||||
}
|
||||
|
||||
export default function CuesheetLinkOptions({ readRef, writeRef }: CuesheetLinkOptionsProps) {
|
||||
interface CuesheetLinkOptionsProps {
|
||||
/** Existing read permission to seed the form with ('full' | '-' | comma separated keys) */
|
||||
initialRead?: string;
|
||||
/** Existing write permission to seed the form with ('full' | '-' | comma separated keys) */
|
||||
initialWrite?: string;
|
||||
/** Notifies the parent whenever the resolved read/write permissions change */
|
||||
onChange: (permissions: CuesheetPermissionValues) => void;
|
||||
}
|
||||
|
||||
/** A null result means "full or unset" - there is no explicit per-column selection to seed */
|
||||
function parseKeys(permission: string | undefined): Set<string> | null {
|
||||
if (permission == null || permission === 'full') {
|
||||
return null;
|
||||
}
|
||||
if (permission === '-') {
|
||||
return new Set();
|
||||
}
|
||||
return new Set(permission.split(','));
|
||||
}
|
||||
|
||||
function modeFromPermission(permission: string | undefined): AccessMode {
|
||||
return permission == null || permission === 'full' ? 'full' : 'custom';
|
||||
}
|
||||
|
||||
export default function CuesheetLinkOptions({ initialRead, initialWrite, onChange }: CuesheetLinkOptionsProps) {
|
||||
const { data } = useCustomFields();
|
||||
const customFieldColumns = useMemo(() => makeCuesheetCustomColumns(data), [data]);
|
||||
const allColumns = useMemo(() => [...cuesheetDefaultColumns, ...customFieldColumns], [customFieldColumns]);
|
||||
|
||||
const [readPermissions, setReadPermissions] = useState<AccessMode>('full');
|
||||
const [writePermissions, setWritePermissions] = useState<AccessMode>('full');
|
||||
// Parsed seed values - stable for the lifetime of a given preset
|
||||
const initialReadKeys = useMemo(() => parseKeys(initialRead), [initialRead]);
|
||||
const initialWriteKeys = useMemo(() => parseKeys(initialWrite), [initialWrite]);
|
||||
|
||||
const [readSwitches, setReadSwitches] = useState<Record<string, boolean>>(() => {
|
||||
const initialState: Record<string, boolean> = {};
|
||||
[...cuesheetDefaultColumns, ...customFieldColumns].forEach((column) => {
|
||||
initialState[column.value] = true;
|
||||
const [readPermissions, setReadPermissions] = useState<AccessMode>(() => modeFromPermission(initialRead));
|
||||
const [writePermissions, setWritePermissions] = useState<AccessMode>(() => modeFromPermission(initialWrite));
|
||||
|
||||
// Default for a column we have not seen yet: honour the seed in custom mode, otherwise grant access
|
||||
const defaultRead = useCallback(
|
||||
(key: string) => (initialReadKeys ? initialReadKeys.has(key) : true),
|
||||
[initialReadKeys],
|
||||
);
|
||||
const defaultWrite = useCallback(
|
||||
(key: string) => (initialWriteKeys ? initialWriteKeys.has(key) : true),
|
||||
[initialWriteKeys],
|
||||
);
|
||||
|
||||
const [readSwitches, setReadSwitches] = useState<Record<string, boolean>>({});
|
||||
const [writeSwitches, setWriteSwitches] = useState<Record<string, boolean>>({});
|
||||
|
||||
// Custom fields load asynchronously, so reconcile the switch maps whenever the column list grows.
|
||||
// Newly seen columns are seeded from the initial values (or default to on for a fresh link).
|
||||
useEffect(() => {
|
||||
setReadSwitches((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const column of allColumns) {
|
||||
if (!(column.value in next)) next[column.value] = defaultRead(column.value);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
return initialState;
|
||||
});
|
||||
|
||||
const [writeSwitches, setWriteSwitches] = useState<Record<string, boolean>>(() => {
|
||||
const initialState: Record<string, boolean> = {};
|
||||
[...cuesheetDefaultColumns, ...customFieldColumns].forEach((column) => {
|
||||
initialState[column.value] = true;
|
||||
setWriteSwitches((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const column of allColumns) {
|
||||
if (!(column.value in next)) next[column.value] = defaultWrite(column.value);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
return initialState;
|
||||
});
|
||||
}, [allColumns, defaultRead, defaultWrite]);
|
||||
|
||||
const isReadOn = (key: string) => readSwitches[key] ?? defaultRead(key);
|
||||
const isWriteOn = (key: string) => writeSwitches[key] ?? defaultWrite(key);
|
||||
|
||||
const handleReadModeChange = (value: AccessMode) => {
|
||||
setReadPermissions(value);
|
||||
|
||||
setReadSwitches((prevReadSwitches) => {
|
||||
const updatedReadSwitches = { ...prevReadSwitches };
|
||||
Object.keys(updatedReadSwitches).forEach((key) => {
|
||||
updatedReadSwitches[key] = true;
|
||||
});
|
||||
return updatedReadSwitches;
|
||||
});
|
||||
};
|
||||
|
||||
const handleWriteModeChange = (value: AccessMode) => {
|
||||
setWritePermissions(value);
|
||||
// Full write implies full read
|
||||
if (value === 'full') {
|
||||
setReadPermissions('full');
|
||||
}
|
||||
setWritePermissions(value);
|
||||
|
||||
setReadSwitches((prevReadSwitches) => {
|
||||
const updatedReadSwitches = { ...prevReadSwitches };
|
||||
setWriteSwitches((prevWriteSwitches) => {
|
||||
const updatedWriteSwitches = { ...prevWriteSwitches };
|
||||
[...cuesheetDefaultColumns, ...customFieldColumns].forEach((column) => {
|
||||
updatedReadSwitches[column.value] = true;
|
||||
updatedWriteSwitches[column.value] = true;
|
||||
});
|
||||
return updatedWriteSwitches;
|
||||
});
|
||||
return updatedReadSwitches;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSwitchChange = (key: string, type: 'read' | 'write', value: boolean) => {
|
||||
if (type === 'read') {
|
||||
setReadSwitches((prevReadSwitches) => {
|
||||
const updatedReadSwitches = { ...prevReadSwitches, [key]: value };
|
||||
return updatedReadSwitches;
|
||||
});
|
||||
} else {
|
||||
setWriteSwitches((prevWriteSwitches) => {
|
||||
const updatedWriteSwitches = { ...prevWriteSwitches, [key]: value };
|
||||
return updatedWriteSwitches;
|
||||
});
|
||||
const handleReadSwitch = (key: string, value: boolean) => {
|
||||
setReadSwitches((prev) => ({ ...prev, [key]: value }));
|
||||
// A column the recipient cannot read cannot be written either
|
||||
if (!value) {
|
||||
setWriteSwitches((prev) => ({ ...prev, [key]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const getReadPermissions = () => {
|
||||
const handleWriteSwitch = (key: string, value: boolean) => {
|
||||
setWriteSwitches((prev) => ({ ...prev, [key]: value }));
|
||||
// Granting write access requires read access
|
||||
if (value) {
|
||||
setReadSwitches((prev) => ({ ...prev, [key]: true }));
|
||||
}
|
||||
};
|
||||
|
||||
const resolvedRead = useMemo(() => {
|
||||
if (readPermissions === 'full' || writePermissions === 'full') {
|
||||
return 'full';
|
||||
}
|
||||
const keys = allColumns.filter((column) => isReadOn(column.value)).map((column) => column.value);
|
||||
return keys.length ? keys.join(',') : '-';
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [readPermissions, writePermissions, readSwitches, allColumns]);
|
||||
|
||||
return Object.entries(readSwitches)
|
||||
.filter(([_, value]) => value)
|
||||
.map(([key]) => key)
|
||||
.join(',');
|
||||
};
|
||||
|
||||
const getWritePermissions = () => {
|
||||
const resolvedWrite = useMemo(() => {
|
||||
if (writePermissions === 'full') {
|
||||
return 'full';
|
||||
}
|
||||
const keys = allColumns.filter((column) => isWriteOn(column.value)).map((column) => column.value);
|
||||
return keys.length ? keys.join(',') : '-';
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [writePermissions, writeSwitches, allColumns]);
|
||||
|
||||
return Object.entries(writeSwitches)
|
||||
.filter(([_, value]) => value)
|
||||
.map(([key]) => key)
|
||||
.join(',');
|
||||
};
|
||||
// Notify the parent of the resolved permissions. onChange is expected to be stable.
|
||||
useEffect(() => {
|
||||
onChange({ read: resolvedRead, write: resolvedWrite });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [resolvedRead, resolvedWrite]);
|
||||
|
||||
const noReadAccess = resolvedRead === '-';
|
||||
|
||||
return (
|
||||
<Panel.Indent>
|
||||
<input name='read' hidden readOnly ref={readRef} value={getReadPermissions() || '-'} />
|
||||
<input name='write' hidden readOnly ref={writeRef} value={getWritePermissions() || '-'} />
|
||||
<div>
|
||||
<Panel.Field title='Access mode' description='Which parts of the data will the link give access to' />
|
||||
<Panel.Field title='Access mode' description='Which parts of the data the link gives access to' />
|
||||
<div>
|
||||
<RadioGroup
|
||||
value={writePermissions}
|
||||
onValueChange={handleWriteModeChange}
|
||||
orientation='horizontal'
|
||||
items={[
|
||||
{ value: 'full', label: 'Full write (edit all existing and future columns)' },
|
||||
{ value: 'custom', label: 'Custom write' },
|
||||
]}
|
||||
/>
|
||||
<RadioGroup
|
||||
value={readPermissions}
|
||||
onValueChange={handleReadModeChange}
|
||||
@@ -132,8 +155,18 @@ export default function CuesheetLinkOptions({ readRef, writeRef }: CuesheetLinkO
|
||||
{ value: 'custom', label: 'Custom read' },
|
||||
]}
|
||||
/>
|
||||
<RadioGroup
|
||||
value={writePermissions}
|
||||
onValueChange={handleWriteModeChange}
|
||||
orientation='horizontal'
|
||||
items={[
|
||||
{ value: 'full', label: 'Full write (edit all existing and future columns)' },
|
||||
{ value: 'custom', label: 'Custom write' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{noReadAccess && <Panel.Error>Links must contain at least one readable column.</Panel.Error>}
|
||||
<div className={style.twoCols}>
|
||||
<div className={style.grid}>
|
||||
<Panel.Description>Ontime columns</Panel.Description>
|
||||
@@ -143,14 +176,14 @@ export default function CuesheetLinkOptions({ readRef, writeRef }: CuesheetLinkO
|
||||
<Fragment key={column.value}>
|
||||
<div>{column.label}</div>
|
||||
<Switch
|
||||
checked={Boolean(readSwitches[column.value])}
|
||||
onCheckedChange={(value: boolean) => handleSwitchChange(column.value, 'read', value)}
|
||||
checked={isReadOn(column.value)}
|
||||
onCheckedChange={(value: boolean) => handleReadSwitch(column.value, value)}
|
||||
disabled={readPermissions === 'full' || writePermissions === 'full'}
|
||||
data-testid={`read-${column.value}`}
|
||||
/>
|
||||
<Switch
|
||||
checked={Boolean(writeSwitches[column.value])}
|
||||
onCheckedChange={(value: boolean) => handleSwitchChange(column.value, 'write', value)}
|
||||
checked={isWriteOn(column.value)}
|
||||
onCheckedChange={(value: boolean) => handleWriteSwitch(column.value, value)}
|
||||
disabled={writePermissions === 'full'}
|
||||
data-testid={`write-${column.value}`}
|
||||
/>
|
||||
@@ -164,16 +197,16 @@ export default function CuesheetLinkOptions({ readRef, writeRef }: CuesheetLinkO
|
||||
<Panel.Description>Write</Panel.Description>
|
||||
{customFieldColumns.map((column) => (
|
||||
<Fragment key={column.value}>
|
||||
{column.label}
|
||||
<div>{column.label}</div>
|
||||
<Switch
|
||||
checked={Boolean(readSwitches[column.value])}
|
||||
onCheckedChange={(value: boolean) => handleSwitchChange(column.value, 'read', value)}
|
||||
checked={isReadOn(column.value)}
|
||||
onCheckedChange={(value: boolean) => handleReadSwitch(column.value, value)}
|
||||
disabled={readPermissions === 'full' || writePermissions === 'full'}
|
||||
data-testid={`read-${column.value}`}
|
||||
/>
|
||||
<Switch
|
||||
checked={Boolean(writeSwitches[column.value])}
|
||||
onCheckedChange={(value: boolean) => handleSwitchChange(column.value, 'write', value)}
|
||||
checked={isWriteOn(column.value)}
|
||||
onCheckedChange={(value: boolean) => handleWriteSwitch(column.value, value)}
|
||||
disabled={writePermissions === 'full'}
|
||||
data-testid={`write-${column.value}`}
|
||||
/>
|
||||
|
||||
@@ -81,6 +81,7 @@ html,
|
||||
user-select: none;
|
||||
-webkit-app-region: drag;
|
||||
isolation: isolate;
|
||||
background-color: $ui-black; // prevent the safari from showing white background
|
||||
}
|
||||
|
||||
// reset button styles
|
||||
|
||||
@@ -82,5 +82,5 @@ export const initializeSentry = () => {
|
||||
},
|
||||
});
|
||||
|
||||
return Sentry.withSentryReactRouterV6Routing(Routes);
|
||||
return Sentry.withSentryReactRouterV7Routing(Routes);
|
||||
};
|
||||
|
||||
@@ -28,6 +28,7 @@ export const langDe: TranslationObject = {
|
||||
'timeline.done': 'Beendet',
|
||||
'timeline.due': 'fällig',
|
||||
'timeline.followedby': 'Gefolgt von',
|
||||
'timeline.standby': 'Bereitschaft',
|
||||
'project.title': 'Titel',
|
||||
'project.description': 'Beschreibung',
|
||||
'project.info': 'Projektinfo',
|
||||
|
||||
@@ -28,6 +28,7 @@ export const langEs: TranslationObject = {
|
||||
'timeline.done': 'Terminado',
|
||||
'timeline.due': 'pendiente',
|
||||
'timeline.followedby': 'Seguido por',
|
||||
'timeline.standby': 'En espera',
|
||||
'project.title': 'Título',
|
||||
'project.description': 'Descripción',
|
||||
'project.info': 'Información del proyecto',
|
||||
|
||||
@@ -28,6 +28,7 @@ export const langFr: TranslationObject = {
|
||||
'timeline.done': 'Terminé',
|
||||
'timeline.due': 'dû',
|
||||
'timeline.followedby': 'Suivi de',
|
||||
'timeline.standby': 'En attente',
|
||||
'project.title': 'Titre',
|
||||
'project.description': 'Description',
|
||||
'project.info': 'Informations du projet',
|
||||
|
||||
@@ -28,6 +28,7 @@ export const langIt: TranslationObject = {
|
||||
'timeline.done': 'Terminato',
|
||||
'timeline.due': 'previsto',
|
||||
'timeline.followedby': 'Seguito da',
|
||||
'timeline.standby': 'In attesa',
|
||||
'project.title': 'Titolo',
|
||||
'project.description': 'Descrizione',
|
||||
'project.info': 'Informazioni sul progetto',
|
||||
|
||||
@@ -28,6 +28,7 @@ export const langPt: TranslationObject = {
|
||||
'timeline.done': 'Concluído',
|
||||
'timeline.due': 'Pendente',
|
||||
'timeline.followedby': 'Seguido por',
|
||||
'timeline.standby': 'Em espera',
|
||||
'project.title': 'Título',
|
||||
'project.description': 'Descrição',
|
||||
'project.info': 'Informações do projeto',
|
||||
|
||||
@@ -24,16 +24,18 @@ export default function ViewLoader({ children }: PropsWithChildren) {
|
||||
const colourFromParams = searchParams.get('keyColour') ?? '#101010';
|
||||
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<>
|
||||
<style>{`body { background: var(--background-color-override, ${colourFromParams}); }`}</style>
|
||||
<Loader />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<OverrideStyles />
|
||||
{children}
|
||||
</Suspense>
|
||||
<>
|
||||
<style>{`body { background: var(--background-color-override, ${colourFromParams}); }`}</style>
|
||||
<Suspense
|
||||
fallback={
|
||||
<>
|
||||
<Loader />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<OverrideStyles />
|
||||
{children}
|
||||
</Suspense>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ export default function BackstageLoader() {
|
||||
|
||||
function Backstage({ events, customFields, projectData, isMirrored, settings }: BackstageData) {
|
||||
const { getLocalizedString } = useTranslation();
|
||||
const { mainSource, secondarySource, extraInfo } = useBackstageOptions();
|
||||
const { mainSource, secondarySource, extraInfo, timeformat } = useBackstageOptions();
|
||||
const { eventNext, eventNow, rundown, selectedEventId, time } = useBackstageSocket();
|
||||
const [blinkClass, setBlinkClass] = useState(false);
|
||||
const { height: screenHeight } = useViewportSize();
|
||||
@@ -71,18 +71,20 @@ function Backstage({ events, customFields, projectData, isMirrored, settings }:
|
||||
|
||||
// gather timer data
|
||||
const isPendingStart = getIsPendingStart(time.playback, time.phase);
|
||||
const startedAt = isPendingStart ? formatTime(time.secondaryTimer) : formatTime(time.startedAt);
|
||||
const startedAt = isPendingStart
|
||||
? formatTime(time.secondaryTimer, { override: timeformat })
|
||||
: formatTime(time.startedAt, { override: timeformat });
|
||||
|
||||
const scheduledStart = (() => {
|
||||
if (showNow) return undefined;
|
||||
if (!hasEvents) return undefined;
|
||||
return formatTime(rundown.plannedStart, { format12: 'h:mm a', format24: 'HH:mm' });
|
||||
return formatTime(rundown.plannedStart, { format12: 'h:mm a', format24: 'HH:mm', override: timeformat });
|
||||
})();
|
||||
|
||||
const scheduledEnd = (() => {
|
||||
if (showNow) return undefined;
|
||||
if (!hasEvents) return undefined;
|
||||
return formatTime(rundown.plannedEnd, { format12: 'h:mm a', format24: 'HH:mm' });
|
||||
return formatTime(rundown.plannedEnd, { format12: 'h:mm a', format24: 'HH:mm', override: timeformat });
|
||||
})();
|
||||
|
||||
let displayTimer = millisToString(time.current, { fallback: timerPlaceholderMin });
|
||||
@@ -107,7 +109,7 @@ function Backstage({ events, customFields, projectData, isMirrored, settings }:
|
||||
<div className='project-header'>
|
||||
{projectData?.logo && <ViewLogo name={projectData.logo} className='logo' />}
|
||||
<div className='title'>{projectData.title}</div>
|
||||
<BackstageClock />
|
||||
<BackstageClock timeformat={timeformat} />
|
||||
</div>
|
||||
|
||||
{showProgress && <ProgressBar className='progress-container' current={time.current} duration={time.duration} />}
|
||||
@@ -131,7 +133,10 @@ function Backstage({ events, customFields, projectData, isMirrored, settings }:
|
||||
{isOvertime(time.current) ? (
|
||||
<div className='time-entry__value'>{getLocalizedString('countdown.overtime')}</div>
|
||||
) : (
|
||||
<SuperscriptTime time={formatTime(time.expectedFinish)} className='time-entry__value' />
|
||||
<SuperscriptTime
|
||||
time={formatTime(time.expectedFinish, { override: timeformat })}
|
||||
className='time-entry__value'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className='timer-gap' />
|
||||
@@ -213,12 +218,12 @@ function ExtraInfo({ projectData, size, source }: ExtraInfoProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function BackstageClock() {
|
||||
function BackstageClock({ timeformat }: { timeformat: string | null }) {
|
||||
const { getLocalizedString } = useTranslation();
|
||||
const clock = useAutoTickingClock();
|
||||
|
||||
// gather timer data
|
||||
const formattedClock = formatTime(clock);
|
||||
const formattedClock = formatTime(clock, { override: timeformat });
|
||||
|
||||
return (
|
||||
<div className='clock-container'>
|
||||
|
||||
@@ -2,7 +2,11 @@ import { CustomFields, OntimeEvent, ProjectData } from 'ontime-types';
|
||||
import { use, useMemo } from 'react';
|
||||
import { useSearchParams } from 'react-router';
|
||||
|
||||
import { getTimeOption } from '../../common/components/view-params-editor/common.options';
|
||||
import {
|
||||
getTimeOption,
|
||||
getTimeOptionsFromParams,
|
||||
TimeOptions,
|
||||
} 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 {
|
||||
@@ -71,7 +75,7 @@ type BackstageOptions = {
|
||||
mainSource: keyof OntimeEvent | null;
|
||||
secondarySource: keyof OntimeEvent | null;
|
||||
extraInfo: string | null;
|
||||
};
|
||||
} & TimeOptions;
|
||||
|
||||
/**
|
||||
* Utility extract the view options from URL Params
|
||||
@@ -85,6 +89,7 @@ function getOptionsFromParams(searchParams: URLSearchParams, defaultValues?: URL
|
||||
mainSource: getValue('main') as keyof OntimeEvent | null,
|
||||
secondarySource: getValue('secondary-src') as keyof OntimeEvent | null,
|
||||
extraInfo: getValue('extra-info'),
|
||||
timeformat: getTimeOptionsFromParams(searchParams, defaultValues),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -101,6 +101,7 @@ export function getPropertyValue(
|
||||
type FormattingOptions = {
|
||||
removeSeconds: boolean;
|
||||
removeLeadingZero: boolean;
|
||||
clockFormat?: MaybeString;
|
||||
};
|
||||
|
||||
export function getFormattedTimer(
|
||||
@@ -114,7 +115,7 @@ export function getFormattedTimer(
|
||||
}
|
||||
|
||||
if (timerType === TimerType.Clock) {
|
||||
return formatTime(timer);
|
||||
return formatTime(timer, { override: options?.clockFormat });
|
||||
}
|
||||
|
||||
let timeToParse = timer;
|
||||
|
||||
@@ -139,11 +139,12 @@ function CountdownContents({ playableEvents, subscriptions, goToEditMode }: Coun
|
||||
}
|
||||
|
||||
function CountdownClock() {
|
||||
const { timeformat } = useCountdownOptions();
|
||||
const { getLocalizedString } = useTranslation();
|
||||
const clock = useAutoTickingClock();
|
||||
|
||||
// gather timer data
|
||||
const formattedClock = formatTime(clock);
|
||||
const formattedClock = formatTime(clock, { override: timeformat });
|
||||
|
||||
return (
|
||||
<div className='clock-container'>
|
||||
|
||||
@@ -2,7 +2,11 @@ import { CustomFields, EntryId, OntimeEvent } from 'ontime-types';
|
||||
import { use, useMemo } from 'react';
|
||||
import { useSearchParams } from 'react-router';
|
||||
|
||||
import { getTimeOption } from '../../common/components/view-params-editor/common.options';
|
||||
import {
|
||||
getTimeOption,
|
||||
getTimeOptionsFromParams,
|
||||
TimeOptions,
|
||||
} 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';
|
||||
@@ -91,7 +95,7 @@ type CountdownOptions = {
|
||||
secondarySource: keyof OntimeEvent | null;
|
||||
showExpected: boolean;
|
||||
hidePast: boolean;
|
||||
};
|
||||
} & TimeOptions;
|
||||
|
||||
/**
|
||||
* Utility extract the view options from URL Params
|
||||
@@ -115,6 +119,7 @@ function getOptionsFromParams(searchParams: URLSearchParams, defaultValues?: URL
|
||||
secondarySource: getValue('secondary-src') as keyof OntimeEvent | null,
|
||||
showExpected: isStringBoolean(getValue('showExpected')),
|
||||
hidePast: isStringBoolean(getValue('hidePast')),
|
||||
timeformat: getTimeOptionsFromParams(searchParams, defaultValues),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -14,3 +14,7 @@
|
||||
'table';
|
||||
color: $ui-white;
|
||||
}
|
||||
|
||||
.rundownSelect {
|
||||
min-width: min(20rem, calc(100vw - 6rem));
|
||||
}
|
||||
|
||||
@@ -4,28 +4,34 @@ import { IoApps } from 'react-icons/io5';
|
||||
import IconButton from '../../common/components/buttons/IconButton';
|
||||
import NavigationMenu from '../../common/components/navigation-menu/NavigationMenu';
|
||||
import { EntryActionsProvider } from '../../common/context/EntryActionsContext';
|
||||
import { useEntryActions } from '../../common/hooks/useEntryAction';
|
||||
import { useScopedRundown } from '../../common/hooks-query/useScopedRundown';
|
||||
import { useScopedEntryActions } from '../../common/hooks/useEntryAction';
|
||||
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
|
||||
import { getIsNavigationLocked } from '../../externals';
|
||||
import CuesheetOverview from '../../features/overview/CuesheetOverview';
|
||||
import EntryEditModal from './cuesheet-edit-modal/EntryEditModal';
|
||||
import CuesheetProgress from './cuesheet-progress/CuesheetProgress';
|
||||
import CuesheetTableWrapper from './CuesheetTableWrapper';
|
||||
import { FOLLOW_LOADED_RUNDOWN_ID, useCuesheetRundownSelection } from './useCuesheetRundownSelection';
|
||||
|
||||
import styles from './CuesheetPage.module.scss';
|
||||
|
||||
export default function CuesheetPage() {
|
||||
'use memo';
|
||||
const [isMenuOpen, menuHandler] = useDisclosure();
|
||||
const entryActions = useEntryActions();
|
||||
const { selectedRundownId, loadedRundownId, setSelectedRundownId, projectRundowns } = useCuesheetRundownSelection();
|
||||
const source = useScopedRundown(selectedRundownId === FOLLOW_LOADED_RUNDOWN_ID ? loadedRundownId : selectedRundownId);
|
||||
|
||||
const actions = useScopedEntryActions(source.rundownId);
|
||||
|
||||
useWindowTitle('Cuesheet');
|
||||
|
||||
const isLocked = getIsNavigationLocked();
|
||||
|
||||
return (
|
||||
<EntryActionsProvider actions={entryActions}>
|
||||
<EntryActionsProvider actions={actions}>
|
||||
<NavigationMenu isOpen={isMenuOpen} onClose={menuHandler.close} />
|
||||
<EntryEditModal />
|
||||
<EntryEditModal rundown={source.rundown} />
|
||||
<div className={styles.tableWrapper} data-testid='cuesheet'>
|
||||
<CuesheetOverview>
|
||||
{!isLocked && (
|
||||
@@ -35,7 +41,13 @@ export default function CuesheetPage() {
|
||||
)}
|
||||
</CuesheetOverview>
|
||||
<CuesheetProgress />
|
||||
<CuesheetTableWrapper />
|
||||
<CuesheetTableWrapper
|
||||
source={source}
|
||||
selectedRundownId={selectedRundownId}
|
||||
loadedRundownId={loadedRundownId}
|
||||
setSelectedRundownId={setSelectedRundownId}
|
||||
projectRundowns={projectRundowns}
|
||||
/>
|
||||
</div>
|
||||
</EntryActionsProvider>
|
||||
);
|
||||
|
||||
@@ -1,18 +1,40 @@
|
||||
import { MaybeString, ProjectRundown } from 'ontime-types';
|
||||
import { memo, use, useMemo } from 'react';
|
||||
|
||||
import Select from '../../common/components/select/Select';
|
||||
import EmptyPage from '../../common/components/state/EmptyPage';
|
||||
import { PresetContext } from '../../common/context/PresetContext';
|
||||
import useCustomFields from '../../common/hooks-query/useCustomFields';
|
||||
import type { RundownSource } from '../../common/hooks-query/useScopedRundown';
|
||||
import { AppMode } from '../../ontimeConfig';
|
||||
import CuesheetDnd from './cuesheet-dnd/CuesheetDnd';
|
||||
import { makeCuesheetColumns } from './cuesheet-table/cuesheet-table-elements/cuesheetColsFactory';
|
||||
import CuesheetTable from './cuesheet-table/CuesheetTable';
|
||||
import { useApplyCuesheetPolicy } from './useApplyCuesheetPolicy';
|
||||
import { FOLLOW_LOADED_RUNDOWN_ID } from './useCuesheetRundownSelection';
|
||||
|
||||
import styles from './CuesheetPage.module.scss';
|
||||
|
||||
interface CuesheetTableWrapperProps {
|
||||
source: RundownSource;
|
||||
selectedRundownId: MaybeString;
|
||||
loadedRundownId: string;
|
||||
setSelectedRundownId: (rundownId: string) => void;
|
||||
projectRundowns: ProjectRundown[];
|
||||
}
|
||||
|
||||
export default memo(CuesheetTableWrapper);
|
||||
function CuesheetTableWrapper() {
|
||||
const { data: customFields, status: customFieldStatus } = useCustomFields();
|
||||
function CuesheetTableWrapper({
|
||||
source,
|
||||
selectedRundownId,
|
||||
setSelectedRundownId,
|
||||
loadedRundownId,
|
||||
projectRundowns,
|
||||
}: CuesheetTableWrapperProps) {
|
||||
const preset = use(PresetContext);
|
||||
const { cuesheetMode, setCuesheetMode } = useApplyCuesheetPolicy(preset);
|
||||
const isCurrentRundown = source.rundownId !== null && source.rundownId === loadedRundownId;
|
||||
const { cuesheetMode, setCuesheetMode } = useApplyCuesheetPolicy(preset, { canRunMode: isCurrentRundown });
|
||||
const { data: customFields, status: customFieldStatus } = useCustomFields();
|
||||
|
||||
const columns = useMemo(
|
||||
() => makeCuesheetColumns(customFields, cuesheetMode, preset),
|
||||
@@ -28,11 +50,66 @@ function CuesheetTableWrapper() {
|
||||
) : (
|
||||
<CuesheetTable
|
||||
columns={columns}
|
||||
source={source}
|
||||
cuesheetMode={cuesheetMode}
|
||||
tableRoot='cuesheet'
|
||||
setCuesheetMode={setCuesheetMode}
|
||||
isCurrentRundown={isCurrentRundown}
|
||||
insertElement={
|
||||
<>
|
||||
<RundownSelect
|
||||
cuesheetMode={cuesheetMode}
|
||||
selectedRundownId={selectedRundownId}
|
||||
loadedRundownId={loadedRundownId}
|
||||
setSelectedRundownId={setSelectedRundownId}
|
||||
projectRundowns={projectRundowns}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</CuesheetDnd>
|
||||
);
|
||||
}
|
||||
|
||||
interface RundownSelectProps {
|
||||
cuesheetMode: AppMode;
|
||||
selectedRundownId: MaybeString;
|
||||
loadedRundownId: string;
|
||||
setSelectedRundownId: (rundownId: string) => void;
|
||||
projectRundowns: ProjectRundown[];
|
||||
}
|
||||
|
||||
function RundownSelect({
|
||||
cuesheetMode,
|
||||
projectRundowns,
|
||||
loadedRundownId,
|
||||
selectedRundownId,
|
||||
setSelectedRundownId,
|
||||
}: RundownSelectProps) {
|
||||
'use memo';
|
||||
const options = projectRundowns.map(({ id, title }) => ({
|
||||
value: id,
|
||||
label: loadedRundownId === id ? `${title} (loaded)` : title,
|
||||
}));
|
||||
options.unshift({
|
||||
value: FOLLOW_LOADED_RUNDOWN_ID,
|
||||
label: 'Follow loaded', // TODO: Better wording and maybe icon? and translation
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={styles.rundownSelect}>
|
||||
<Select
|
||||
value={selectedRundownId ?? undefined}
|
||||
options={options}
|
||||
onValueChange={(value) => {
|
||||
if (value) {
|
||||
setSelectedRundownId(value);
|
||||
}
|
||||
}}
|
||||
disabled={cuesheetMode === AppMode.Run}
|
||||
fluid
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ describe('getCuesheetPermissionsPolicy()', () => {
|
||||
alias: 'cuesheet-read-only',
|
||||
target: OntimeView.Cuesheet,
|
||||
search: '',
|
||||
displayInNav: false,
|
||||
options: {
|
||||
read: 'full',
|
||||
write: '-',
|
||||
@@ -41,6 +42,7 @@ describe('getCuesheetPermissionsPolicy()', () => {
|
||||
alias: 'cuesheet-flag',
|
||||
target: OntimeView.Cuesheet,
|
||||
search: '',
|
||||
displayInNav: false,
|
||||
options: {
|
||||
read: 'full',
|
||||
write: 'flag',
|
||||
@@ -62,6 +64,7 @@ describe('getCuesheetPermissionsPolicy()', () => {
|
||||
alias: 'cuesheet-default',
|
||||
target: OntimeView.Cuesheet,
|
||||
search: '',
|
||||
displayInNav: false,
|
||||
};
|
||||
|
||||
const policy = getCuesheetColumnAccessPolicy(preset, AppMode.Edit);
|
||||
@@ -76,6 +79,7 @@ describe('getCuesheetPermissionsPolicy()', () => {
|
||||
alias: 'cuesheet-granular',
|
||||
target: OntimeView.Cuesheet,
|
||||
search: '',
|
||||
displayInNav: false,
|
||||
options: {
|
||||
read: 'cue,title',
|
||||
write: 'title',
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { AppMode } from '../../../ontimeConfig';
|
||||
import { getEffectiveCuesheetMode } from '../useApplyCuesheetPolicy';
|
||||
|
||||
describe('getEffectiveCuesheetMode()', () => {
|
||||
it('honors the stored mode when the user can change mode on the current rundown', () => {
|
||||
const permissions = { canChangeMode: true };
|
||||
|
||||
expect(getEffectiveCuesheetMode(permissions, AppMode.Edit, { canRunMode: true })).toBe(AppMode.Edit);
|
||||
expect(getEffectiveCuesheetMode(permissions, AppMode.Run, { canRunMode: true })).toBe(AppMode.Run);
|
||||
});
|
||||
|
||||
it('forces Run mode when the preset forbids mode changes', () => {
|
||||
const permissions = { canChangeMode: false };
|
||||
|
||||
expect(getEffectiveCuesheetMode(permissions, AppMode.Edit, { canRunMode: true })).toBe(AppMode.Run);
|
||||
});
|
||||
|
||||
it('forces Edit mode for background rundowns without changing the permission policy', () => {
|
||||
const permissions = { canChangeMode: true };
|
||||
|
||||
expect(getEffectiveCuesheetMode(permissions, AppMode.Run, { canRunMode: false })).toBe(AppMode.Edit);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import {
|
||||
FOLLOW_LOADED_RUNDOWN_ID,
|
||||
getCuesheetRundownStorageKey,
|
||||
resolveSelectedRundownId,
|
||||
} from '../useCuesheetRundownSelection';
|
||||
|
||||
describe('useCuesheetRundownSelection helpers', () => {
|
||||
it('builds a project-scoped storage key', () => {
|
||||
expect(getCuesheetRundownStorageKey('http://localhost:4001', 'My Project')).toBe(
|
||||
'cuesheet-selected-rundown:http://localhost:4001:My Project',
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to the follow loaded rundown when the stored selection is missing', () => {
|
||||
expect(resolveSelectedRundownId('missing', new Set(['loaded', 'other']))).toBe(FOLLOW_LOADED_RUNDOWN_ID);
|
||||
});
|
||||
|
||||
it('keeps the stored selection when it still exists in the current project', () => {
|
||||
expect(resolveSelectedRundownId('other', new Set(['loaded', 'other']))).toBe('other');
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,16 @@
|
||||
import { Rundown } from 'ontime-types';
|
||||
import { memo } from 'react';
|
||||
|
||||
import Modal from '../../../common/components/modal/Modal';
|
||||
import CuesheetEntryEditor from '../../../features/rundown/entry-editor/CuesheetEventEditor';
|
||||
import { useEditModal } from './useEditModal';
|
||||
|
||||
interface EntryEditModalProps {
|
||||
rundown: Rundown;
|
||||
}
|
||||
|
||||
export default memo(EntryEditModal);
|
||||
function EntryEditModal() {
|
||||
function EntryEditModal({ rundown }: EntryEditModalProps) {
|
||||
const entryId = useEditModal((state) => state.selectedEntryId);
|
||||
const closeModal = useEditModal((state) => state.clearSelection);
|
||||
|
||||
@@ -19,7 +24,7 @@ function EntryEditModal() {
|
||||
onClose={closeModal}
|
||||
title='Edit entry'
|
||||
showCloseButton
|
||||
bodyElements={<CuesheetEntryEditor entryId={entryId} />}
|
||||
bodyElements={<CuesheetEntryEditor entryId={entryId} rundown={rundown} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useTableNav } from '@table-nav/react';
|
||||
import { ColumnDef, Table, getCoreRowModel, useReactTable } from '@tanstack/react-table';
|
||||
import { OntimeEntry, TimeField, isOntimeDelay, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
|
||||
import { ComponentProps, memo, useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { OntimeEntry, SupportedEntry, TimeField, isOntimeDelay, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
|
||||
import { ComponentProps, ReactNode, memo, useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import {
|
||||
ContextProp,
|
||||
ItemProps,
|
||||
@@ -14,21 +14,20 @@ import {
|
||||
import EmptyPage from '../../../common/components/state/EmptyPage';
|
||||
import EmptyTableBody from '../../../common/components/state/EmptyTableBody';
|
||||
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
|
||||
import { useFlatRundownWithMetadata } from '../../../common/hooks-query/useRundown';
|
||||
import { useSelectedEventId } from '../../../common/hooks/useSocket';
|
||||
import type { RundownSource } from '../../../common/hooks-query/useScopedRundown';
|
||||
import type { ExtendedEntry } from '../../../common/utils/rundownMetadata';
|
||||
import EditorTableSettings from '../../../features/rundown/rundown-table/EditorTableSettings';
|
||||
import { usePersistedRundownOptions } from '../../../features/rundown/rundown.options';
|
||||
import { useEventSelection } from '../../../features/rundown/useEventSelection';
|
||||
import { AppMode } from '../../../ontimeConfig';
|
||||
import { usePersistedCuesheetOptions } from '../cuesheet.options';
|
||||
import { useCuesheetPermissions } from '../useTablePermissions';
|
||||
import { CuesheetHeader, SortableCuesheetHeader } from './cuesheet-table-elements/CuesheetHeader';
|
||||
import DelayRow from './cuesheet-table-elements/DelayRow';
|
||||
import EventRow from './cuesheet-table-elements/EventRow';
|
||||
import GroupRow from './cuesheet-table-elements/GroupRow';
|
||||
import MilestoneRow from './cuesheet-table-elements/MilestoneRow';
|
||||
import TableMenu from './cuesheet-table-menu/TableMenu';
|
||||
import CuesheetTableSettings from './cuesheet-table-settings/CuesheetTableSettings';
|
||||
import CuesheetTableHeaderToolbar from './cuesheet-table-settings/CuesheetTableHeaderToolbar';
|
||||
import { useColumnOrder, useColumnSizes, useColumnVisibility } from './useColumnManager';
|
||||
|
||||
import style from './CuesheetTable.module.scss';
|
||||
@@ -36,30 +35,41 @@ import style from './CuesheetTable.module.scss';
|
||||
type CuesheetTableBaseProps = {
|
||||
columns: ColumnDef<ExtendedEntry>[];
|
||||
cuesheetMode: AppMode;
|
||||
source: RundownSource;
|
||||
insertElement?: ReactNode;
|
||||
};
|
||||
|
||||
type EditorCuesheetTableProps = CuesheetTableBaseProps & {
|
||||
tableRoot: 'editor';
|
||||
setCuesheetMode?: undefined;
|
||||
isCurrentRundown?: undefined;
|
||||
};
|
||||
|
||||
type ViewCuesheetTableProps = CuesheetTableBaseProps & {
|
||||
tableRoot: 'cuesheet';
|
||||
setCuesheetMode: (mode: AppMode) => void;
|
||||
isCurrentRundown?: boolean;
|
||||
};
|
||||
|
||||
type CuesheetTableProps = EditorCuesheetTableProps | ViewCuesheetTableProps;
|
||||
|
||||
export default function CuesheetTable({ columns, cuesheetMode, tableRoot, setCuesheetMode }: CuesheetTableProps) {
|
||||
const { data, status } = useFlatRundownWithMetadata();
|
||||
const { updateEntry, updateTimer } = useEntryActionsContext();
|
||||
export default function CuesheetTable({
|
||||
columns,
|
||||
cuesheetMode,
|
||||
source,
|
||||
tableRoot,
|
||||
setCuesheetMode,
|
||||
isCurrentRundown,
|
||||
insertElement,
|
||||
}: CuesheetTableProps) {
|
||||
const { flatRundown, status, selectedEventId } = source;
|
||||
const { updateEntry, updateTimer, addEntry } = useEntryActionsContext();
|
||||
const canCreateEntries = useCuesheetPermissions((state) => state.canCreateEntries) && cuesheetMode === AppMode.Edit;
|
||||
|
||||
const useOptions = tableRoot === 'editor' ? usePersistedRundownOptions : usePersistedCuesheetOptions;
|
||||
const showDelayedTimes = useOptions((state) => state.showDelayedTimes);
|
||||
const hideTableSeconds = useOptions((state) => state.hideTableSeconds);
|
||||
const hideIndexColumn = useOptions((state) => state.hideIndexColumn);
|
||||
const optionsStore = useOptions();
|
||||
const { showDelayedTimes, hideTableSeconds, hideIndexColumn } = optionsStore;
|
||||
|
||||
const selectedEventId = useSelectedEventId();
|
||||
const cursor = useEventSelection((state) => state.cursor);
|
||||
const setScrollHandler = useEventSelection((state) => state.setScrollHandler);
|
||||
|
||||
@@ -70,7 +80,7 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot, setCue
|
||||
() => ({
|
||||
handleUpdate: (rowIndex: number, accessor: string, payload: string, isCustom = false) => {
|
||||
// check if value is the same
|
||||
const event = data[rowIndex];
|
||||
const event = flatRundown[rowIndex];
|
||||
|
||||
if (!event) {
|
||||
return;
|
||||
@@ -101,7 +111,7 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot, setCue
|
||||
hideIndexColumn,
|
||||
},
|
||||
}),
|
||||
[cuesheetMode, data, hideIndexColumn, hideTableSeconds, showDelayedTimes, updateEntry, updateTimer],
|
||||
[cuesheetMode, flatRundown, hideIndexColumn, hideTableSeconds, showDelayedTimes, updateEntry, updateTimer],
|
||||
);
|
||||
|
||||
const { columnOrder, resetColumnOrder } = useColumnOrder(columns, tableRoot);
|
||||
@@ -109,7 +119,7 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot, setCue
|
||||
const { columnVisibility, setColumnVisibility } = useColumnVisibility(tableRoot);
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
data: flatRundown,
|
||||
columns,
|
||||
columnResizeMode: 'onChange',
|
||||
state: {
|
||||
@@ -137,13 +147,13 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot, setCue
|
||||
return;
|
||||
}
|
||||
|
||||
const eventIndex = data.findIndex((event) => event.id === selectedEventId);
|
||||
const eventIndex = flatRundown.findIndex((event) => event.id === selectedEventId);
|
||||
if (eventIndex === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
virtuosoRef.current.scrollToIndex({ index: eventIndex, behavior: 'auto', align: 'start', offset: -50 });
|
||||
}, [cuesheetMode, data, selectedEventId]);
|
||||
}, [cuesheetMode, flatRundown, selectedEventId]);
|
||||
|
||||
// Provide an imperative scroll handler for explicit jumps (finder/keyboard)
|
||||
useEffect(() => {
|
||||
@@ -152,7 +162,7 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot, setCue
|
||||
return;
|
||||
}
|
||||
|
||||
const eventIndex = data.findIndex((event) => event.id === entryId);
|
||||
const eventIndex = flatRundown.findIndex((event) => event.id === entryId);
|
||||
if (eventIndex === -1) {
|
||||
return;
|
||||
}
|
||||
@@ -165,7 +175,7 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot, setCue
|
||||
return () => {
|
||||
setScrollHandler(null);
|
||||
};
|
||||
}, [data, setScrollHandler]);
|
||||
}, [flatRundown, setScrollHandler]);
|
||||
|
||||
/**
|
||||
* To improve performance on resizing, we memoise the column sizes
|
||||
@@ -194,8 +204,9 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot, setCue
|
||||
listeners,
|
||||
rows,
|
||||
table,
|
||||
handleAddNew: canCreateEntries ? (type: SupportedEntry) => addEntry({ type }) : undefined,
|
||||
}),
|
||||
[columnSizeVars, cursor, listeners, rows, table],
|
||||
[columnSizeVars, cursor, listeners, rows, table, addEntry, canCreateEntries],
|
||||
);
|
||||
|
||||
const computeItemKey = useCallback((_: number, item: ExtendedEntry) => item.id, []);
|
||||
@@ -217,7 +228,7 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot, setCue
|
||||
});
|
||||
}, [cuesheetMode, hideIndexColumn, table]);
|
||||
|
||||
const isLoading = !data || status === 'pending';
|
||||
const isLoading = !flatRundown || status === 'pending';
|
||||
|
||||
if (isLoading) {
|
||||
return <EmptyPage text='Loading...' />;
|
||||
@@ -225,26 +236,27 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot, setCue
|
||||
|
||||
return (
|
||||
<>
|
||||
{tableRoot === 'editor' ? (
|
||||
<EditorTableSettings
|
||||
columns={allLeafColumns}
|
||||
handleResetResizing={resetColumnResizing}
|
||||
handleResetReordering={resetColumnOrder}
|
||||
handleClearToggles={setAllVisible}
|
||||
/>
|
||||
) : (
|
||||
<CuesheetTableSettings
|
||||
columns={allLeafColumns}
|
||||
cuesheetMode={cuesheetMode}
|
||||
setCuesheetMode={setCuesheetMode}
|
||||
handleResetResizing={resetColumnResizing}
|
||||
handleResetReordering={resetColumnOrder}
|
||||
handleClearToggles={setAllVisible}
|
||||
/>
|
||||
)}
|
||||
<CuesheetTableHeaderToolbar
|
||||
columns={allLeafColumns}
|
||||
optionsStore={optionsStore}
|
||||
handleResetResizing={resetColumnResizing}
|
||||
handleResetReordering={resetColumnOrder}
|
||||
handleClearToggles={setAllVisible}
|
||||
insertElement={insertElement}
|
||||
modeControls={
|
||||
tableRoot === 'cuesheet'
|
||||
? {
|
||||
cuesheetMode,
|
||||
setCuesheetMode,
|
||||
isCurrentRundown,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
showShare={tableRoot === 'cuesheet'}
|
||||
/>
|
||||
<TableVirtuoso
|
||||
ref={virtuosoRef}
|
||||
data={data}
|
||||
data={flatRundown}
|
||||
context={virtuosoContext}
|
||||
style={tableRoot === 'editor' ? { paddingLeft: '1rem' } : undefined}
|
||||
computeItemKey={computeItemKey}
|
||||
@@ -264,10 +276,13 @@ interface CuesheetVirtuosoContext {
|
||||
listeners: ReturnType<typeof useTableNav>['listeners'];
|
||||
rows: ReturnType<Table<ExtendedEntry>['getRowModel']>['rows'];
|
||||
table: Table<ExtendedEntry>;
|
||||
handleAddNew?: (type: SupportedEntry) => void;
|
||||
}
|
||||
|
||||
const EmptyPlaceholder = memo(function EmptyPlaceholder() {
|
||||
return <EmptyTableBody text='No data in rundown' />;
|
||||
const EmptyPlaceholder = memo(function EmptyPlaceholder({
|
||||
context,
|
||||
}: TableProps & ContextProp<CuesheetVirtuosoContext>) {
|
||||
return <EmptyTableBody handleAddNew={context.handleAddNew} />;
|
||||
});
|
||||
|
||||
const CuesheetTableElement = memo(function CuesheetTableElement({
|
||||
|
||||
+6
@@ -49,6 +49,10 @@ function DurationInput({
|
||||
setIsEditing(false);
|
||||
setTimeout(() => textRef.current?.focusParentElement()); // Immediate timeout to ensure state change takes place first
|
||||
};
|
||||
const handleTabOut = useCallback(() => {
|
||||
setIsEditing(false);
|
||||
// Tab moves focus naturally; do not steal it back to parent
|
||||
}, []);
|
||||
|
||||
const handleUpdate = useCallback(
|
||||
(newValue: string) => {
|
||||
@@ -95,8 +99,10 @@ function DurationInput({
|
||||
ref={inputRef}
|
||||
initialValue={timeString}
|
||||
allowSubmitSameValue={!lockedValue} // if the value is not locked, submitting will lock the value
|
||||
submitOnTab={false}
|
||||
handleUpdate={handleUpdate}
|
||||
handleCancelUpdate={handleFakeBlur}
|
||||
handleTabCancel={handleTabOut}
|
||||
/>
|
||||
) : (
|
||||
<TextLikeInput
|
||||
|
||||
+4
-3
@@ -30,15 +30,16 @@ function EditableImage({ initialValue, readOnly, updateValue }: EditableImagePro
|
||||
}
|
||||
};
|
||||
|
||||
if (!initialValue && readOnly) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!initialValue) {
|
||||
return (
|
||||
<Input
|
||||
variant='ghosted'
|
||||
className={style.imageInput}
|
||||
fluid
|
||||
readOnly={readOnly}
|
||||
// we disable the field to prevent receiving focus
|
||||
disabled={readOnly}
|
||||
placeholder='Paste image URL'
|
||||
onBlur={(event) => handleUpdate(event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
|
||||
+17
-15
@@ -1,7 +1,7 @@
|
||||
import { Table, flexRender } from '@tanstack/react-table';
|
||||
import { EntryId, OntimeEntry, RGBColour, SupportedEntry } from 'ontime-types';
|
||||
import { colourToHex, cssOrHexToColour } from 'ontime-utils';
|
||||
import { CSSProperties, useMemo } from 'react';
|
||||
import { CSSProperties, memo, useMemo } from 'react';
|
||||
import { IoEllipsisHorizontal } from 'react-icons/io5';
|
||||
|
||||
import IconButton from '../../../../common/components/buttons/IconButton';
|
||||
@@ -30,7 +30,8 @@ interface EventRowProps {
|
||||
hasCursor?: boolean;
|
||||
}
|
||||
|
||||
export default function EventRow({
|
||||
export default memo(EventRow);
|
||||
function EventRow({
|
||||
rowId,
|
||||
id,
|
||||
eventIndex,
|
||||
@@ -55,24 +56,25 @@ export default function EventRow({
|
||||
|
||||
const openMenu = useCuesheetTableMenu((store) => store.openMenu);
|
||||
|
||||
const { color, backgroundColor } = getAccessibleColour(colour);
|
||||
const tmpColour = cssOrHexToColour(color) as RGBColour; // we know this to be a correct colour
|
||||
const mutedText = colourToHex({ ...tmpColour, alpha: tmpColour.alpha * 0.8 });
|
||||
const { rowBgColour, backgroundColor, mutedText } = useMemo(() => {
|
||||
const accessible = getAccessibleColour(colour);
|
||||
const tmpColour = cssOrHexToColour(accessible.color) as RGBColour;
|
||||
|
||||
const rowBgColour: string | undefined = useMemo(() => {
|
||||
let rowBgColour: string | undefined;
|
||||
if (isLoaded) {
|
||||
return '#087A27'; // $active-green
|
||||
rowBgColour = '#087A27'; // $active-green
|
||||
} else if (colour) {
|
||||
// the colour is user defined and might be invalid
|
||||
const accessibleBackgroundColor = cssOrHexToColour(getAccessibleColour(colour).backgroundColor);
|
||||
if (accessibleBackgroundColor !== null) {
|
||||
return colourToHex({
|
||||
...accessibleBackgroundColor,
|
||||
alpha: accessibleBackgroundColor.alpha * 0.25,
|
||||
});
|
||||
const accessibleBg = cssOrHexToColour(accessible.backgroundColor);
|
||||
if (accessibleBg !== null) {
|
||||
rowBgColour = colourToHex({ ...accessibleBg, alpha: accessibleBg.alpha * 0.25 });
|
||||
}
|
||||
}
|
||||
return;
|
||||
|
||||
return {
|
||||
rowBgColour,
|
||||
backgroundColor: accessible.backgroundColor,
|
||||
mutedText: colourToHex({ ...tmpColour, alpha: tmpColour.alpha * 0.8 }),
|
||||
};
|
||||
}, [colour, isLoaded]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Table, flexRender } from '@tanstack/react-table';
|
||||
import { EntryId, SupportedEntry } from 'ontime-types';
|
||||
import { CSSProperties } from 'react';
|
||||
import { CSSProperties, memo } from 'react';
|
||||
import { IoEllipsisHorizontal } from 'react-icons/io5';
|
||||
|
||||
import IconButton from '../../../../common/components/buttons/IconButton';
|
||||
@@ -20,7 +20,8 @@ interface GroupRowProps {
|
||||
hasCursor?: boolean;
|
||||
}
|
||||
|
||||
export default function GroupRow({
|
||||
export default memo(GroupRow);
|
||||
function GroupRow({
|
||||
groupId,
|
||||
colour,
|
||||
rowId,
|
||||
|
||||
+9
-13
@@ -1,7 +1,7 @@
|
||||
import { Table, flexRender } from '@tanstack/react-table';
|
||||
import { EntryId, SupportedEntry } from 'ontime-types';
|
||||
import { colourToHex, cssOrHexToColour } from 'ontime-utils';
|
||||
import { CSSProperties } from 'react';
|
||||
import { CSSProperties, memo, useMemo } from 'react';
|
||||
import { IoEllipsisHorizontal } from 'react-icons/io5';
|
||||
|
||||
import IconButton from '../../../../common/components/buttons/IconButton';
|
||||
@@ -25,7 +25,8 @@ interface MilestoneRowProps {
|
||||
hasCursor?: boolean;
|
||||
}
|
||||
|
||||
export default function MilestoneRow({
|
||||
export default memo(MilestoneRow);
|
||||
function MilestoneRow({
|
||||
entryId,
|
||||
isPast,
|
||||
parentBgColour,
|
||||
@@ -45,17 +46,12 @@ export default function MilestoneRow({
|
||||
|
||||
const openMenu = useCuesheetTableMenu((store) => store.openMenu);
|
||||
|
||||
let rowBgColour: string | undefined;
|
||||
if (colour) {
|
||||
// the colour is user defined and might be invalid
|
||||
const accessibleBackgroundColor = cssOrHexToColour(getAccessibleColour(colour).backgroundColor);
|
||||
if (accessibleBackgroundColor !== null) {
|
||||
rowBgColour = colourToHex({
|
||||
...accessibleBackgroundColor,
|
||||
alpha: accessibleBackgroundColor.alpha * 0.25,
|
||||
});
|
||||
}
|
||||
}
|
||||
const rowBgColour = useMemo(() => {
|
||||
if (!colour) return undefined;
|
||||
const accessibleBg = cssOrHexToColour(getAccessibleColour(colour).backgroundColor);
|
||||
if (accessibleBg === null) return undefined;
|
||||
return colourToHex({ ...accessibleBg, alpha: accessibleBg.alpha * 0.25 });
|
||||
}, [colour]);
|
||||
|
||||
return (
|
||||
<tr
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user