Compare commits

...

17 Commits

Author SHA1 Message Date
Carlos Valente aa927597f3 fix: product name ontime 2025-10-10 19:03:53 +02:00
Carlos Valente ef7f0fb54a refactor: bump modal sizes 2025-10-10 18:32:29 +02:00
Carlos Valente 324320fdc8 fix: custom field is multiline 2025-10-10 18:32:29 +02:00
Carlos Valente 4b27d080b6 fix: background colour in not found page 2025-10-10 18:13:58 +02:00
Carlos Valente 7918063753 bump version to 4.0.0 2025-10-10 18:13:58 +02:00
arc-alex e2202c2417 fix: dynamic full hight for mobile view param editor 2025-10-10 18:07:39 +02:00
Carlos Valente 9817c8f8c0 fix: selected styling overrides 2025-10-10 17:41:26 +02:00
Carlos Valente 1d8c04d0b4 feat: export excel file
refactor: align id columns between export and import

refactor: match column names with template
2025-10-10 17:41:26 +02:00
Carlos Valente 10762f10bf docs: caveat on proxy not working before project build 2025-10-10 17:41:26 +02:00
Alex Christoffer Rasmussen da7c0d4ff2 Small clean-up (#1815)
* chore: remove old comment

* chore: smal lclean up of a e2e test

* chore: bump to beta.6

* chore: cleanup imports

* fix integration values com in ms

* allow linkStart and timeStrategy from API

* remove local mac dist
2025-10-10 17:37:55 +02:00
Carlos Valente 4a27973de1 refactor: allow locking op settings and navigation 2025-10-10 16:01:41 +02:00
Carlos Valente bdeb087e28 fix: op gets params from preset context 2025-10-10 16:01:41 +02:00
Carlos Valente 3662cd58f5 refactor: extract operator data loading 2025-10-10 16:01:41 +02:00
Carlos Valente 62c52bca07 refactor: larger secondary field in timer 2025-10-09 21:03:22 +02:00
Carlos Valente 1fc4a06133 fix: allow user to reset schedule filter 2025-10-09 21:03:22 +02:00
Alex Christoffer Rasmussen 147862e647 Fix opview with groups (#1814)
* create failing test

* fix: op evet used past state of the group
2025-10-08 21:19:57 +02:00
Alex Christoffer Rasmussen 12bc323e57 Fix build (#1813)
* fix resolver build

* fix docker build

* node version in docker via arg

* add step to setup auth for pnpm

* lint

---------

Co-authored-by: Joel Wetzell <jwetzell@yahoo.com>
2025-10-08 10:44:53 +02:00
59 changed files with 925 additions and 223 deletions
-1
View File
@@ -17,7 +17,6 @@
# Ignore build folders # Ignore build folders
node_modules node_modules
**/node_modules **/node_modules
**/dist
# Ignore default volumes created by running docker compose up # Ignore default volumes created by running docker compose up
ontime-db ontime-db
+9 -5
View File
@@ -13,13 +13,13 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4
- name: Get pnpm store directory - name: Get pnpm store directory
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache - name: Setup pnpm cache
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
@@ -38,16 +38,20 @@ jobs:
- name: Copy server - name: Copy server
run: mkdir -p apps/cli/server && cp apps/server/dist/index.cjs apps/cli/server/index.cjs run: mkdir -p apps/cli/server && cp apps/server/dist/index.cjs apps/cli/server/index.cjs
- name: Copy client - name: Copy client
run: cp -R apps/client/build apps/cli/client run: cp -R apps/client/build apps/cli/client
- name: Copy external - name: Copy external
run: cp -R apps/server/src/external apps/cli/external run: cp -R apps/server/src/external apps/cli/external
- name: Setup pnpm auth config
run: pnpm config set '//registry.npmjs.org/:_authToken' "${NODE_AUTH_TOKEN}"
env:
NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}
- name: Publish to NPM - name: Publish to NPM
run: pnpm publish --access public --no-git-checks run: pnpm publish --access public --no-git-checks
env: env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
NPM_CONFIG_PROVENANCE: true NPM_CONFIG_PROVENANCE: true
working-directory: ./apps/cli working-directory: ./apps/cli
+49 -29
View File
@@ -6,43 +6,63 @@ on:
workflow_dispatch: workflow_dispatch:
jobs: jobs:
publish_docker: publish_docker:
runs-on: ubuntu-latest runs-on: ubuntu-latest
env: env:
CI: '' CI: ''
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Docker Login - name: Setup pnpm
uses: docker/login-action@v2.1.0 uses: pnpm/action-setup@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Docker Setup Buildx - name: Get pnpm store directory
uses: docker/setup-buildx-action@v2.5.0 run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Build and push stable release - name: Setup pnpm cache
if: github.event.release.prerelease == false uses: actions/cache@v4
uses: docker/build-push-action@v4.0.0 with:
with: path: ${{ env.PNPM_STORE_PATH }}
context: . key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
file: ./Dockerfile restore-keys: |
platforms: linux/amd64,linux/arm64,linux/arm/v7 ${{ runner.os }}-pnpm-store-
# Push is a shorthand for --output=type=registry
push: true
tags: ${{ secrets.DOCKERHUB_USERNAME }}/ontime:${{ github.event.release.tag_name }} , ${{ secrets.DOCKERHUB_USERNAME }}/ontime:latest
- name: Build and push pre-release - name: Install dependencies
if: github.event.release.prerelease == true run: pnpm install --frozen-lockfile
uses: docker/build-push-action@v4.0.0
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7
# Push is a shorthand for --output=type=registry
push: true
tags: ${{ secrets.DOCKERHUB_USERNAME }}/ontime:${{ github.event.release.tag_name }} , ${{ secrets.DOCKERHUB_USERNAME }}/ontime:nightly
- name: Build project packages
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
run: pnpm build:docker
- name: Docker Login
uses: docker/login-action@v2.1.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Docker Setup Buildx
uses: docker/setup-buildx-action@v2.5.0
- name: Build and push stable release
if: github.event.release.prerelease == false
uses: docker/build-push-action@v4.0.0
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7
# Push is a shorthand for --output=type=registry
push: true
tags: ${{ secrets.DOCKERHUB_USERNAME }}/ontime:${{ github.event.release.tag_name }} , ${{ secrets.DOCKERHUB_USERNAME }}/ontime:latest
- name: Build and push pre-release
if: github.event.release.prerelease == true
uses: docker/build-push-action@v4.0.0
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7
# Push is a shorthand for --output=type=registry
push: true
tags: ${{ secrets.DOCKERHUB_USERNAME }}/ontime:${{ github.event.release.tag_name }} , ${{ secrets.DOCKERHUB_USERNAME }}/ontime:nightly
+4 -4
View File
@@ -2,7 +2,7 @@ name: Ontime build
on: on:
push: push:
tags: [ "*" ] tags: ['*']
workflow_dispatch: workflow_dispatch:
jobs: jobs:
@@ -11,13 +11,13 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4
- name: Get pnpm store directory - name: Get pnpm store directory
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache - name: Setup pnpm cache
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
@@ -35,7 +35,7 @@ jobs:
run: pnpm build run: pnpm build
- name: Electron - Build app - name: Electron - Build app
env: env:
APPLE_ID: ${{ secrets.APPLEID }} APPLE_ID: ${{ secrets.APPLEID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLEIDPASS }} APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLEIDPASS }}
APPLE_TEAM_ID: ${{ secrets.TEAMID }} APPLE_TEAM_ID: ${{ secrets.TEAMID }}
+7 -3
View File
@@ -13,13 +13,13 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4
- name: Get pnpm store directory - name: Get pnpm store directory
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache - name: Setup pnpm cache
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
@@ -34,9 +34,13 @@ jobs:
- name: Build project packages - name: Build project packages
run: pnpm build:resolver run: pnpm build:resolver
- name: Setup pnpm auth config
run: pnpm config set '//registry.npmjs.org/:_authToken' "${NODE_AUTH_TOKEN}"
env:
NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}
- name: Publish to NPM - name: Publish to NPM
run: pnpm publish --access public --no-git-checks run: pnpm publish --access public --no-git-checks
env: env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
NPM_CONFIG_PROVENANCE: true NPM_CONFIG_PROVENANCE: true
working-directory: ./apps/resolver working-directory: ./apps/resolver
+5 -5
View File
@@ -20,7 +20,7 @@ jobs:
- name: Get pnpm store directory - name: Get pnpm store directory
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache - name: Setup pnpm cache
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
@@ -32,10 +32,10 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
# Run code quality # Run code quality
- name: Run linter - name: Run linter
run: pnpm lint run: pnpm lint
- name: Run TypeScript checks - name: Run TypeScript checks
run: pnpm typecheck run: pnpm typecheck
@@ -56,7 +56,7 @@ jobs:
- name: Get pnpm store directory - name: Get pnpm store directory
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache - name: Setup pnpm cache
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
@@ -74,7 +74,7 @@ jobs:
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
path: | path: |
~/.cache/ms-playwright ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ env.PLAYWRIGHT_VERSION }} key: ${{ runner.os }}-playwright-${{ env.PLAYWRIGHT_VERSION }}
restore-keys: | restore-keys: |
${{ runner.os }}-playwright- ${{ runner.os }}-playwright-
+2 -1
View File
@@ -5,8 +5,9 @@ node_modules
playwright-report playwright-report
pnpm-lock.yaml
**/*.toml **/*.toml
**/*.yml
**/*.json **/*.json
!tsconfig.common.json !tsconfig.common.json
!turbo.json !turbo.json
+1 -3
View File
@@ -21,6 +21,7 @@ Locally, we would need to run both the React client and the node.js server in de
From the project root, run the following commands From the project root, run the following commands
- __Install the project dependencies__ by running `pnpm i` - __Install the project dependencies__ by running `pnpm i`
- __Create a local build__ by running `pnpm build`, this will populate local dependencies
- __Run dev mode__ by running `pnpm dev` or `pnpm dev:electron` to get the electron window - __Run dev mode__ by running `pnpm dev` or `pnpm dev:electron` to get the electron window
@@ -82,9 +83,6 @@ From the project root, run the following commands
The build distribution assets will be at `.apps/electron/dist` The build distribution assets will be at `.apps/electron/dist`
Note: The MacOS build will only work in CI, locally it will fail due to notarisation issues.
Use the `pnpm dist-mac:local` command to build a MacOS distribution locally and skip the notary process.
## DOCKER ## DOCKER
Ontime provides a docker-compose file to aid with building and running docker images. Ontime provides a docker-compose file to aid with building and running docker images.
+9 -15
View File
@@ -1,13 +1,5 @@
FROM node:22-bullseye AS builder ARG NODE_VERSION=22.15.1
ENV PNPM_HOME="/pnpm" FROM node:${NODE_VERSION}-alpine
ENV PATH="$PNPM_HOME:$PATH"
RUN npm install -g pnpm@10.11.0
COPY . /app
WORKDIR /app
RUN pnpm --filter=ontime-ui --filter=ontime-server --filter=ontime-utils --filter=ontime-types install --config.dedupe-peer-dependents=false --frozen-lockfile
RUN pnpm run build:docker
FROM node:22-alpine
# Set environment variables # Set environment variables
# Environment Variable to signal that we are running production # Environment Variable to signal that we are running production
@@ -15,16 +7,17 @@ ENV NODE_ENV=docker
# Ontime Data path # Ontime Data path
ENV ONTIME_DATA=/data/ ENV ONTIME_DATA=/data/
RUN mkdir /app
WORKDIR /app/ WORKDIR /app/
# Prepare UI # Prepare UI
COPY --from=builder /app/apps/client/build ./client/ COPY apps/client/build/ ./client/
# Prepare Backend # Prepare Backend
COPY --from=builder /app/apps/server/dist/ ./server/ COPY apps/server/dist/ ./server/
COPY --from=builder /app/apps/server/src/external/ ./external/ COPY apps/server/src/external/ ./external/
COPY --from=builder /app/apps/server/src/user/ ./user/ COPY apps/server/src/user/ ./user/
COPY --from=builder /app/apps/server/src/html/ ./html/ COPY apps/server/src/html/ ./html/
# Export default ports # Export default ports
EXPOSE 4001/tcp 8888/udp 9999/udp EXPOSE 4001/tcp 8888/udp 9999/udp
@@ -32,5 +25,6 @@ EXPOSE 4001/tcp 8888/udp 9999/udp
CMD ["node", "server/docker.cjs"] CMD ["node", "server/docker.cjs"]
# Build and run commands # Build and run commands
# pnpm build:docker
# docker buildx build . -t getontime/ontime # docker buildx build . -t getontime/ontime
# docker run -p 4001:4001 -p 8888:8888/udp -p 9999:9999/udp -v ./ontime-db:/data/ getontime/ontime # docker run -p 4001:4001 -p 8888:8888/udp -p 9999:9999/udp -v ./ontime-db:/data/ getontime/ontime
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getontime/cli", "name": "@getontime/cli",
"version": "4.0.0-beta.5", "version": "4.0.0",
"author": "Carlos Valente", "author": "Carlos Valente",
"description": "Time keeping for live events", "description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime", "repository": "https://github.com/cpvalente/ontime",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "ontime-ui", "name": "ontime-ui",
"version": "4.0.0-beta.5", "version": "4.0.0",
"private": true, "private": true,
"type": "module", "type": "module",
"dependencies": { "dependencies": {
+4 -3
View File
@@ -102,6 +102,7 @@ export default function AppRouter() {
path='op' path='op'
element={ element={
<ViewLoader> <ViewLoader>
<ViewNavigationMenu isNavigationLocked={getIsNavigationLocked()} />
<Operator /> <Operator />
</ViewLoader> </ViewLoader>
} }
@@ -207,13 +208,13 @@ function PresetView() {
/** /**
* Locked presets do not allow configuration changes * Locked presets do not allow configuration changes
* Whether the user can navigate is determined by the locked param * Whether the user can navigate is determined by the locked param
*
* We inject the preset to the context value for the view to consume
*/ */
const Component = PresetViewMap[preset.target as OntimeViewPresettable]; const Component = PresetViewMap[preset.target as OntimeViewPresettable];
return ( return (
<PresetContext value={preset}> <PresetContext value={preset}>
{preset.target !== OntimeView.Cuesheet && ( <ViewNavigationMenu isNavigationLocked={getIsNavigationLocked()} suppressSettings />
<ViewNavigationMenu isNavigationLocked={getIsNavigationLocked()} suppressSettings />
)}
{Component ? <Component /> : <NotFound />} {Component ? <Component /> : <NotFound />}
</PresetContext> </PresetContext>
); );
+16
View File
@@ -3,6 +3,7 @@ import { CustomFields, Rundown } from 'ontime-types';
import { ImportMap } from 'ontime-utils'; import { ImportMap } from 'ontime-utils';
import { apiEntryUrl } from './constants'; import { apiEntryUrl } from './constants';
import { downloadBlob } from './utils';
const excelPath = `${apiEntryUrl}/excel`; const excelPath = `${apiEntryUrl}/excel`;
@@ -39,3 +40,18 @@ export async function importRundownPreview(options: ImportMap): Promise<PreviewS
}); });
return response.data; return response.data;
} }
/**
* Downloads a xlsx representation of the rundown from the server
*/
export async function downloadAsExcel(rundownId: string, fileName?: string) {
try {
const response = await axios.get(`${excelPath}/${rundownId}/export`, {
responseType: 'blob',
});
downloadBlob(response.data, `${fileName ?? 'Ontime_rundown'}.xlsx`);
} catch (error) {
console.error('Error downloading file:', error);
}
}
@@ -1,6 +1,5 @@
import { PropsWithChildren, useRef, useState } from 'react'; import { PropsWithChildren, useRef, useState } from 'react';
import { IoCheckmark } from 'react-icons/io5'; import { IoCheckmark, IoCopy } from 'react-icons/io5';
import { IoCopy } from 'react-icons/io5';
import copyToClipboard from '../../utils/copyToClipboard'; import copyToClipboard from '../../utils/copyToClipboard';
import { cx } from '../../utils/styleUtils'; import { cx } from '../../utils/styleUtils';
@@ -5,9 +5,9 @@
transform: translateX(-50%); transform: translateX(-50%);
padding-inline: 1rem; padding-inline: 1rem;
min-width: min(680px, 90vw); min-width: min(880px, 90vw);
min-height: min(200px, 10vh); min-height: min(200px, 10vh);
max-width: min(900px, 90vw); max-width: min(1200px, 90vw);
background-color: $gray-1250; background-color: $gray-1250;
color: $ui-white; color: $ui-white;
@@ -1,5 +1,4 @@
import { IoApps } from 'react-icons/io5'; import { IoApps, IoSettingsOutline } from 'react-icons/io5';
import { IoSettingsOutline } from 'react-icons/io5';
import { useFadeOutOnInactivity } from '../../../hooks/useFadeOutOnInactivity'; import { useFadeOutOnInactivity } from '../../../hooks/useFadeOutOnInactivity';
import { cx } from '../../../utils/styleUtils'; import { cx } from '../../../utils/styleUtils';
@@ -30,6 +30,7 @@
width: 40rem; width: 40rem;
max-width: 100vw; max-width: 100vw;
height: 100vh; height: 100vh;
height: 100dvh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -77,7 +77,7 @@ export default function OntimeActionForm({
New time New time
<Input <Input
{...register(`outputs.${index}.time`, { {...register(`outputs.${index}.time`, {
required: { value: true, message: 'Required field' }, //TODO:(automation set aux) not sure what way around to have the string and where to have the ms value required: { value: true, message: 'Required field' },
})} })}
fluid fluid
placeholder='eg: 10m5s' placeholder='eg: 10m5s'
@@ -13,5 +13,5 @@
} }
.current { .current {
background-color: $blue-1100; background-color: $blue-1100 !important; // fighting zebra styles
} }
@@ -1,10 +1,13 @@
import { useState } from 'react'; import { useState } from 'react';
import { IoAdd } from 'react-icons/io5'; import { IoAdd, IoDocumentOutline, IoDownloadOutline, IoEllipsisHorizontal, IoTrash } from 'react-icons/io5';
import { useDisclosure } from '@mantine/hooks'; import { useDisclosure } from '@mantine/hooks';
import { downloadAsExcel } from '../../../../common/api/excel';
import { maybeAxiosError } from '../../../../common/api/utils'; import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button'; import Button from '../../../../common/components/buttons/Button';
import IconButton from '../../../../common/components/buttons/IconButton';
import Dialog from '../../../../common/components/dialog/Dialog'; import Dialog from '../../../../common/components/dialog/Dialog';
import { DropdownMenu } from '../../../../common/components/dropdown-menu/DropdownMenu';
import Tag from '../../../../common/components/tag/Tag'; import Tag from '../../../../common/components/tag/Tag';
import { useMutateProjectRundowns, useProjectRundowns } from '../../../../common/hooks-query/useProjectRundowns'; import { useMutateProjectRundowns, useProjectRundowns } from '../../../../common/hooks-query/useProjectRundowns';
import { cx } from '../../../../common/utils/styleUtils'; import { cx } from '../../../../common/utils/styleUtils';
@@ -55,6 +58,10 @@ export default function ManageRundowns() {
} }
}; };
const handleDownloadXlsx = async (rundownId: string, title: string) => {
await downloadAsExcel(rundownId, title);
};
return ( return (
<> <>
<Panel.Section> <Panel.Section>
@@ -93,19 +100,35 @@ export default function ManageRundowns() {
<td> <td>
{title} {isLoaded && <Tag>Loaded</Tag>} {title} {isLoaded && <Tag>Loaded</Tag>}
</td> </td>
<Panel.InlineElements as='td'> <td>
<Button size='small' onClick={() => openLoad(id)} disabled={isLoaded}> <DropdownMenu
Load render={<IconButton variant='ghosted-white' />}
</Button> items={[
<Button {
size='small' type: 'item',
variant='subtle-destructive' icon: IoDownloadOutline,
onClick={() => openDelete(id)} label: 'Load',
disabled={isLoaded} onClick: () => openLoad(id),
disabled: isLoaded,
},
{
type: 'item',
icon: IoDocumentOutline,
label: 'Download .xlsx',
onClick: () => handleDownloadXlsx(id, title),
},
{
type: 'item',
icon: IoTrash,
label: 'Delete',
onClick: () => openDelete(id),
disabled: isLoaded,
},
]}
> >
Delete <IoEllipsisHorizontal />
</Button> </DropdownMenu>
</Panel.InlineElements> </td>
</tr> </tr>
); );
})} })}
@@ -59,7 +59,7 @@ export function convertToImportMap(namedImportMap: NamedImportMap): ImportMap {
timeWarning: namedImportMap['Time warning'], timeWarning: namedImportMap['Time warning'],
timeDanger: namedImportMap['Time danger'], timeDanger: namedImportMap['Time danger'],
custom, custom,
entryId: namedImportMap.ID, id: namedImportMap.ID,
}; };
} }
+22 -22
View File
@@ -6,13 +6,10 @@ import ViewParamsEditor from '../../common/components/view-params-editor/ViewPar
import useFollowComponent from '../../common/hooks/useFollowComponent'; import useFollowComponent from '../../common/hooks/useFollowComponent';
import { useSelectedEventId } from '../../common/hooks/useSocket'; import { useSelectedEventId } from '../../common/hooks/useSocket';
import { useWindowTitle } from '../../common/hooks/useWindowTitle'; import { useWindowTitle } from '../../common/hooks/useWindowTitle';
import useCustomFields from '../../common/hooks-query/useCustomFields';
import useProjectData from '../../common/hooks-query/useProjectData';
import { useRundownWithMetadata } from '../../common/hooks-query/useRundown';
import useSettings from '../../common/hooks-query/useSettings';
import { cx } from '../../common/utils/styleUtils'; import { cx } from '../../common/utils/styleUtils';
import { throttle } from '../../common/utils/throttle'; import { throttle } from '../../common/utils/throttle';
import { getDefaultFormat } from '../../common/utils/time'; import { getDefaultFormat } from '../../common/utils/time';
import Loader from '../../views/common/loader/Loader';
import EditModal from './edit-modal/EditModal'; import EditModal from './edit-modal/EditModal';
import FollowButton from './follow-button/FollowButton'; import FollowButton from './follow-button/FollowButton';
@@ -22,21 +19,31 @@ import StatusBar from './status-bar/StatusBar';
import { getOperatorOptions, useOperatorOptions } from './operator.options'; import { getOperatorOptions, useOperatorOptions } from './operator.options';
import type { EditEvent } from './operator.types'; import type { EditEvent } from './operator.types';
import { getEventData } from './operator.utils'; import { getEventData } from './operator.utils';
import { OperatorData, useOperatorData } from './useOperatorData';
import style from './Operator.module.scss'; import style from './Operator.module.scss';
const selectedOffset = 50; const selectedOffset = 50;
export default function Operator() { export default function OperatorLoader() {
const { data, rundownMetadata, status } = useRundownWithMetadata(); const { data, status } = useOperatorData();
const { data: customFields, status: customFieldStatus } = useCustomFields();
const { data: projectData, status: projectDataStatus } = useProjectData();
const timeoutId = useRef<NodeJS.Timeout | null>(null); useWindowTitle('Operator');
if (status === 'pending') {
return <Loader />;
}
if (status === 'error') {
return <EmptyPage text='There was an error fetching data, please refresh the page.' />;
}
return <Operator {...data} />;
}
function Operator({ rundown, rundownMetadata, customFields, settings }: OperatorData) {
const { selectedEventId } = useSelectedEventId(); const { selectedEventId } = useSelectedEventId();
const { subscribe, mainSource, secondarySource, shouldEdit, hidePast, showStart } = useOperatorOptions(); const { subscribe, mainSource, secondarySource, shouldEdit, hidePast, showStart } = useOperatorOptions();
const { data: settings } = useSettings();
const [showEditPrompt, setShowEditPrompt] = useState(false); const [showEditPrompt, setShowEditPrompt] = useState(false);
const [editEvent, setEditEvent] = useState<EditEvent | null>(null); const [editEvent, setEditEvent] = useState<EditEvent | null>(null);
@@ -52,7 +59,7 @@ export default function Operator() {
followTrigger: selectedEventId, followTrigger: selectedEventId,
}); });
useWindowTitle('Operator'); const timeoutId = useRef<NodeJS.Timeout | null>(null);
// reset scroll if nothing is selected // reset scroll if nothing is selected
useEffect(() => { useEffect(() => {
@@ -101,17 +108,10 @@ export default function Operator() {
setEditEvent({ ...event }); setEditEvent({ ...event });
}, []); }, []);
const missingData = !data || !customFields || !projectData;
const isLoading = status === 'pending' || customFieldStatus === 'pending' || projectDataStatus === 'pending';
// gather option data // gather option data
const defaultFormat = getDefaultFormat(settings?.timeFormat); const defaultFormat = getDefaultFormat(settings?.timeFormat);
const operatorOptions = useMemo(() => getOperatorOptions(customFields, defaultFormat), [customFields, defaultFormat]); const operatorOptions = useMemo(() => getOperatorOptions(customFields, defaultFormat), [customFields, defaultFormat]);
if (missingData || isLoading) {
return <EmptyPage text='Loading...' />;
}
const canEdit = shouldEdit && subscribe.length; const canEdit = shouldEdit && subscribe.length;
return ( return (
@@ -126,8 +126,8 @@ export default function Operator() {
)} )}
<div className={style.operatorEvents} onWheel={handleScroll} onTouchMove={handleScroll} ref={scrollRef}> <div className={style.operatorEvents} onWheel={handleScroll} onTouchMove={handleScroll} ref={scrollRef}>
{data.order.map((entryId) => { {rundown.order.map((entryId) => {
const entry = data.entries[entryId]; const entry = rundown.entries[entryId];
if (isOntimeEvent(entry)) { if (isOntimeEvent(entry)) {
const { isPast, isLinkedToLoaded, isLoaded, totalGap } = rundownMetadata[entryId]; const { isPast, isLinkedToLoaded, isLoaded, totalGap } = rundownMetadata[entryId];
// hide past events (if setting) and skipped events // hide past events (if setting) and skipped events
@@ -172,12 +172,12 @@ export default function Operator() {
<Fragment key={entry.id}> <Fragment key={entry.id}>
<OperatorGroup key={entry.id} title={entry.title} /> <OperatorGroup key={entry.id} title={entry.title} />
{entry.entries.map((nestedEntryId) => { {entry.entries.map((nestedEntryId) => {
const nestedEntry = data.entries[nestedEntryId]; const nestedEntry = rundown.entries[nestedEntryId];
if (!isOntimeEvent(nestedEntry)) { if (!isOntimeEvent(nestedEntry)) {
return null; return null;
} }
const { isPast, isLoaded, isLinkedToLoaded, totalGap } = rundownMetadata[entryId]; const { isPast, isLoaded, isLinkedToLoaded, totalGap } = rundownMetadata[nestedEntryId];
// hide past events (if setting) and skipped events // hide past events (if setting) and skipped events
if ((hidePast && isPast) || nestedEntry.skip) { if ((hidePast && isPast) || nestedEntry.skip) {
@@ -1,13 +1,10 @@
import ViewNavigationMenu from '../../common/components/navigation-menu/ViewNavigationMenu';
import ProtectRoute from '../../common/components/protect-route/ProtectRoute'; import ProtectRoute from '../../common/components/protect-route/ProtectRoute';
import { getIsNavigationLocked } from '../../externals';
import Operator from './Operator'; import Operator from './Operator';
export default function OperatorExport() { export default function OperatorExport() {
return ( return (
<ProtectRoute permission='operator'> <ProtectRoute permission='operator'>
<ViewNavigationMenu isNavigationLocked={getIsNavigationLocked()} />
<Operator /> <Operator />
</ProtectRoute> </ProtectRoute>
); );
@@ -1,6 +1,6 @@
import { useMemo } from 'react'; import { use, useMemo } from 'react';
import { useSearchParams } from 'react-router'; import { useSearchParams } from 'react-router';
import { CustomFields, OntimeEvent } from 'ontime-types'; import { CustomFields, EntryId, OntimeEvent } from 'ontime-types';
import { getTimeOption } from '../../common/components/view-params-editor/common.options'; import { getTimeOption } from '../../common/components/view-params-editor/common.options';
import { OptionTitle } from '../../common/components/view-params-editor/constants'; import { OptionTitle } from '../../common/components/view-params-editor/constants';
@@ -9,6 +9,7 @@ import {
makeCustomFieldSelectOptions, makeCustomFieldSelectOptions,
makeOptionsFromCustomFields, makeOptionsFromCustomFields,
} from '../../common/components/view-params-editor/viewParams.utils'; } from '../../common/components/view-params-editor/viewParams.utils';
import { PresetContext } from '../../common/context/PresetContext';
import { isStringBoolean } from '../viewers/common/viewUtils'; import { isStringBoolean } from '../viewers/common/viewUtils';
export const getOperatorOptions = (customFields: CustomFields, timeFormat: string): ViewOption[] => { export const getOperatorOptions = (customFields: CustomFields, timeFormat: string): ViewOption[] => {
@@ -93,15 +94,26 @@ type OperatorOptions = {
* Utility extract the view options from URL Params * Utility extract the view options from URL Params
* the names and fallback are manually matched with timerOptions * the names and fallback are manually matched with timerOptions
*/ */
function getOptionsFromParams(searchParams: URLSearchParams): OperatorOptions { function getOptionsFromParams(searchParams: URLSearchParams, defaultValues?: URLSearchParams): OperatorOptions {
// Helper to get value from either source, prioritizing defaultValues
const getValue = (key: string) => defaultValues?.get(key) ?? searchParams.get(key);
// Helper to get array values from either source
const getArrayValues = (key: string): EntryId[] => {
if (defaultValues?.has(key)) {
return defaultValues.getAll(key) as EntryId[];
}
return searchParams.getAll(key) as EntryId[];
};
// we manually make an object that matches the key above // we manually make an object that matches the key above
return { return {
mainSource: searchParams.get('main') as keyof OntimeEvent | null, mainSource: getValue('main') as keyof OntimeEvent | null,
secondarySource: searchParams.get('secondary-src') as keyof OntimeEvent | null, secondarySource: getValue('secondary-src') as keyof OntimeEvent | null,
subscribe: searchParams.getAll('subscribe'), subscribe: getArrayValues('subscribe'),
shouldEdit: isStringBoolean(searchParams.get('shouldEdit')), shouldEdit: isStringBoolean(getValue('shouldEdit')),
hidePast: isStringBoolean(searchParams.get('hidePast')), hidePast: isStringBoolean(getValue('hidePast')),
showStart: isStringBoolean(searchParams.get('showStart')), showStart: isStringBoolean(getValue('showStart')),
}; };
} }
@@ -110,6 +122,12 @@ function getOptionsFromParams(searchParams: URLSearchParams): OperatorOptions {
*/ */
export function useOperatorOptions(): OperatorOptions { export function useOperatorOptions(): OperatorOptions {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const options = useMemo(() => getOptionsFromParams(searchParams), [searchParams]); const maybePreset = use(PresetContext);
const options = useMemo(() => {
const defaultValues = maybePreset ? new URLSearchParams(maybePreset.search) : undefined;
return getOptionsFromParams(searchParams, defaultValues);
}, [maybePreset, searchParams]);
return options; return options;
} }
@@ -0,0 +1,30 @@
import { CustomFields, Rundown, Settings } from 'ontime-types';
import useCustomFields from '../../common/hooks-query/useCustomFields';
import { useRundownWithMetadata } from '../../common/hooks-query/useRundown';
import useSettings from '../../common/hooks-query/useSettings';
import { RundownMetadataObject } from '../../common/utils/rundownMetadata';
import { aggregateQueryStatus, ViewData } from '../../views/utils/viewLoader.utils';
export interface OperatorData {
rundown: Rundown;
rundownMetadata: RundownMetadataObject;
customFields: CustomFields;
settings: Settings;
}
export function useOperatorData(): ViewData<OperatorData> {
const { data: rundown, rundownMetadata, status: rundownStatus } = useRundownWithMetadata();
const { data: customFields, status: customFieldStatus } = useCustomFields();
const { data: settings, status: settingsStatus } = useSettings();
return {
data: {
rundown,
rundownMetadata,
customFields,
settings,
},
status: aggregateQueryStatus([rundownStatus, customFieldStatus, settingsStatus]),
};
}
+1 -2
View File
@@ -1,6 +1,5 @@
import React from 'react'; import React from 'react';
import { createRoutesFromChildren, matchRoutes, useLocation, useNavigationType } from 'react-router'; import { createRoutesFromChildren, matchRoutes, Routes, useLocation, useNavigationType } from 'react-router';
import { Routes } from 'react-router';
import * as Sentry from '@sentry/react'; import * as Sentry from '@sentry/react';
import { ONTIME_VERSION } from './ONTIME_VERSION'; import { ONTIME_VERSION } from './ONTIME_VERSION';
@@ -17,7 +17,7 @@ export const getBackstageOptions = (
customFields: CustomFields, customFields: CustomFields,
projectData: ProjectData, projectData: ProjectData,
): ViewOption[] => { ): ViewOption[] => {
const customFieldOptions = makeOptionsFromCustomFields(customFields, []); const customFieldOptions = makeOptionsFromCustomFields(customFields, [{ value: 'none', label: 'None' }]);
const secondaryOptions = makeOptionsFromCustomFields(customFields, [ const secondaryOptions = makeOptionsFromCustomFields(customFields, [
{ value: 'none', label: 'None' }, { value: 'none', label: 'None' },
{ value: 'note', label: 'Note' }, { value: 'note', label: 'Note' },
@@ -2,10 +2,16 @@
.notFound { .notFound {
display: grid; display: grid;
place-items: center; height: 100vh;
padding-top: 20vh;
background-color: var(--background-color-override, $viewer-background-color); background-color: var(--background-color-override, $viewer-background-color);
margin-top: 10vh; }
.content {
display: flex;
flex-direction: column;
color: $ui-white; color: $ui-white;
justify-content: center; align-items: center;
text-align: center; text-align: center;
} }
@@ -5,15 +5,17 @@ import style from './NotFound.module.scss';
export default function NotFound() { export default function NotFound() {
return ( return (
<div className={style.notFound}> <div className={style.notFound}>
<EmptyImage /> <section className={style.content}>
<h1>Not found</h1> <EmptyImage />
<div> <h1>Not found</h1>
The page you are after was not found. <div>
<br /> The page you are after was not found.
It may have moved or your URL may be incorrect. <br />
<br /> It may have moved or your URL may be incorrect.
Double check the URL and try again. <br />
</div> Double check the URL and try again.
</div>
</section>
</div> </div>
); );
} }
@@ -16,7 +16,7 @@ export const getScheduleOptions = (customFieldOptions: SelectOption[]): ViewOpti
description: 'Hide events without data in the selected custom field', description: 'Hide events without data in the selected custom field',
type: 'option', type: 'option',
values: customFieldOptions, values: customFieldOptions,
defaultValue: 'None', defaultValue: 'none',
}, },
{ {
id: 'stopCycle', id: 'stopCycle',
@@ -216,7 +216,7 @@ function MakeCustomField({ row, column, table }: CellContext<ExtendedEntry, unkn
const canWrite = column.columnDef.meta?.canWrite; const canWrite = column.columnDef.meta?.canWrite;
if (!canWrite) { if (!canWrite) {
return <GhostedText>{initialValue}</GhostedText>; return <GhostedText multiline>{initialValue}</GhostedText>;
} }
return <MultiLineCell initialValue={initialValue} handleUpdate={update} />; return <MultiLineCell initialValue={initialValue} handleUpdate={update} />;
+1 -1
View File
@@ -58,7 +58,7 @@ export function getEstimatedFontSize(stageTimer: string, secondaryContent?: stri
timerFontSize = fontSizeMap[stageTimerCharacters]; timerFontSize = fontSizeMap[stageTimerCharacters];
} }
let externalFontSize = timerFontSize * 0.2; let externalFontSize = timerFontSize * 0.325;
if (secondaryContent) { if (secondaryContent) {
// we need to shrink the timer if the external is going to be there // we need to shrink the timer if the external is going to be there
// this number has been tweaked to fit in a landscape mobile screen // this number has been tweaked to fit in a landscape mobile screen
+2 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "ontime-electron", "name": "ontime-electron",
"version": "4.0.0-beta.5", "version": "4.0.0",
"author": "Carlos Valente", "author": "Carlos Valente",
"description": "Time keeping for live events", "description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime", "repository": "https://github.com/cpvalente/ontime",
@@ -25,11 +25,10 @@
"lint": "eslint . --quiet", "lint": "eslint . --quiet",
"dist-win": "electron-builder --publish=never --x64 --win", "dist-win": "electron-builder --publish=never --x64 --win",
"dist-mac": "electron-builder --publish=never --mac", "dist-mac": "electron-builder --publish=never --mac",
"dist-mac:local": "electron-builder --publish=never --mac -c.mac.identity=null",
"dist-linux": "electron-builder --publish=never --x64 --linux" "dist-linux": "electron-builder --publish=never --x64 --linux"
}, },
"build": { "build": {
"productName": "ontime-prerelease", "productName": "ontime",
"appId": "no.lightdev.ontime", "appId": "no.lightdev.ontime",
"asar": true, "asar": true,
"dmg": { "dmg": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getontime/resolver", "name": "@getontime/resolver",
"version": "4.0.0-beta.5", "version": "4.0.0",
"type": "module", "type": "module",
"repository": "https://github.com/cpvalente/ontime", "repository": "https://github.com/cpvalente/ontime",
"types": "./dist/main.d.ts", "types": "./dist/main.d.ts",
+9 -2
View File
@@ -13,8 +13,15 @@ export { SimplePlayback, SimpleDirection } from 'ontime-types';
// entries // entries
export type { OntimeEvent, OntimeGroup, EntryCustomFields, CustomFields, Rundown } from 'ontime-types'; export type { OntimeEvent, OntimeGroup, EntryCustomFields, CustomFields, Rundown } from 'ontime-types';
export { SupportedEntry, isOntimeEvent, isOntimeGroup, isOntimeDelay, isOntimeMilestone } from 'ontime-types'; export {
SupportedEntry,
isOntimeEvent,
isOntimeGroup,
isOntimeDelay,
isOntimeMilestone,
TimeStrategy,
} from 'ontime-types';
// functions // functions
export { isWsPacketToClient } from './websocket.js'; export { isWsPacketToClient } from './websocket.js';
export type { SocketSender } from './websocket.js'; export type { SocketSender } from './websocket.js';
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "ontime-server", "name": "ontime-server",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"version": "4.0.0-beta.5", "version": "4.0.0",
"exports": "./src/index.js", "exports": "./src/index.js",
"dependencies": { "dependencies": {
"@googleapis/sheets": "^5.0.5", "@googleapis/sheets": "^5.0.5",
@@ -199,7 +199,7 @@ function parseOntimeAction(maybeOntimeAction: object): OntimeAction {
return { return {
type: 'ontime', type: 'ontime',
action: maybeOntimeAction.action, action: maybeOntimeAction.action,
time: maybeOntimeAction.time, //TODO:(automation set aux) not sure what way around to have the string and where to have the ms value time: maybeOntimeAction.time,
}; };
} }
@@ -0,0 +1,363 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`rundownToTabular() > returns an array of arrays describing a rundown 1`] = `
[
[
"ID",
"Flag",
"Cue",
"Title",
"Colour",
"Time Start",
"Time End",
"Duration",
"Link Start",
"Count to end",
"Note",
"Timer Type",
"End Action",
"Warning Time",
"Danger Time",
"Skip",
"Custom Field",
"Images",
],
[
"e2163f",
"",
"Demo",
"Clear all, or Create New Project to start fresh",
"#9d9d9d",
"",
"",
"",
"",
"",
"Go to Settings cog wheel top left of Editor, Project > Create",
"milestone",
"",
"",
"",
"",
"Create Custom Fields to store specific text information or images.
Go to Settings > Project data > Custom fields",
"https://www.getontime.no/images/icons/ontime-logo.png",
],
[
"7eaf99",
"",
"",
"Morning Sessions",
"#339E4E",
"10:00:00",
"12:00:00",
"",
"",
"",
"",
"group",
"",
"",
"",
"",
"",
"",
],
[
"9bf60f",
"",
"1",
"Pre-show Countdown",
"#77C785",
"10:00:00",
"11:00:00",
"01:00:00",
"",
"",
"Music plays, holding slide on screens",
"count-down",
"",
"00:10:00",
"00:05:00",
"",
"Put additional info here",
"",
],
[
"bf71a2",
"",
"Standby",
"10:45 - Presenters ready side stage",
"#A790F5",
"",
"",
"",
"",
"",
"",
"milestone",
"",
"",
"",
"",
"",
"",
],
[
"c2697f",
"",
"1.1",
"Welcome",
"#FFCC78",
"11:00:00",
"11:10:00",
"00:10:00",
"TRUE",
"",
"Emma Thompson",
"count-down",
"",
"00:02:00",
"00:01:00",
"",
"",
"",
],
[
"fa593e",
"TRUE",
"1.2",
"Session 1",
"#77C785",
"11:10:00",
"12:00:00",
"00:50:00",
"TRUE",
"",
"Liam Carter, Sophia Patel + PowerPoint",
"count-down",
"",
"00:02:00",
"00:01:00",
"",
"",
"",
],
[
"a8b0b3",
"",
"House",
"11:30 - House staff setup lunch in lobby",
"#A790F5",
"",
"",
"",
"",
"",
"",
"milestone",
"",
"",
"",
"",
"",
"",
],
[
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"group-end",
"",
"",
"",
"",
],
[
"f60403",
"",
"",
"Lunch",
"#3E75E8",
"12:00:00",
"13:00:00",
"",
"",
"",
"",
"group",
"",
"",
"",
"",
"",
"",
],
[
"0aaa7d",
"",
"2.1",
"Lunch / Countdown to next session",
"#779BE7",
"12:00:00",
"13:00:00",
"01:00:00",
"TRUE",
"",
"Buffet in lobby
Music plays, holding slide on screens",
"count-down",
"",
"00:02:00",
"00:01:00",
"",
"",
"",
],
[
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"group-end",
"",
"",
"",
"",
],
[
"6b0edb",
"",
"",
"Afternoon Sessions",
"#339E4E",
"13:00:00",
"14:00:00",
"",
"",
"",
"",
"group",
"",
"",
"",
"",
"",
"",
],
[
"02afca",
"",
"Standby",
"12:45 - Presenters ready side stage",
"#A790F5",
"",
"",
"",
"",
"",
"",
"milestone",
"",
"",
"",
"",
"",
"",
],
[
"75ce86",
"",
"3.1",
"Session 2",
"#77C785",
"13:00:00",
"13:50:00",
"00:50:00",
"TRUE",
"",
"Ethan Brooks + PowerPoint + Video playback",
"count-down",
"",
"00:02:00",
"00:01:00",
"",
"",
"",
],
[
"e10ed9",
"",
"3.2",
"Wrap up",
"#FFCC78",
"13:50:00",
"14:00:00",
"00:10:00",
"",
"",
"Lucas Bennett",
"count-down",
"",
"00:02:00",
"00:01:00",
"",
"",
"",
],
[
"07df89",
"",
"Strike",
"14:00 - AV & Room strike",
"#A790F5",
"",
"",
"",
"",
"",
"",
"milestone",
"",
"",
"",
"",
"",
"",
],
[
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"group-end",
"",
"",
"",
"",
],
]
`;
@@ -1,11 +1,13 @@
import { CustomFields } from 'ontime-types'; import { CustomFields } from 'ontime-types';
import { defaultImportMap, ImportMap } from 'ontime-utils'; import { defaultImportMap, ImportMap } from 'ontime-utils';
import { getCustomFieldData } from '../excel.utils.js'; import { demoDb } from '../../../models/demoProject.js';
import { getCustomFieldData, rundownToTabular } from '../excel.utils.js';
describe('getCustomFieldData()', () => { describe('getCustomFieldData()', () => {
it('generates a list of keys from the given import map', () => { it('generates a list of keys from the given import map', () => {
const importMap = { const importMap: ImportMap = {
worksheet: 'event schedule', worksheet: 'event schedule',
timeStart: 'time start', timeStart: 'time start',
linkStart: 'link start', linkStart: 'link start',
@@ -27,8 +29,8 @@ describe('getCustomFieldData()', () => {
sound: 'sound', sound: 'sound',
video: 'av', video: 'av',
}, },
entryId: 'id', id: 'id',
} as ImportMap; };
const result = getCustomFieldData(importMap, {}); const result = getCustomFieldData(importMap, {});
expect(result.mergedCustomFields).toStrictEqual({ expect(result.mergedCustomFields).toStrictEqual({
@@ -58,7 +60,7 @@ describe('getCustomFieldData()', () => {
}); });
it('keeps colour information from existing fields', () => { it('keeps colour information from existing fields', () => {
const importMap = { const importMap: ImportMap = {
worksheet: 'event schedule', worksheet: 'event schedule',
timeStart: 'time start', timeStart: 'time start',
linkStart: 'link start', linkStart: 'link start',
@@ -81,8 +83,8 @@ describe('getCustomFieldData()', () => {
video: 'av', video: 'av',
'ontime key': 'excel label', 'ontime key': 'excel label',
}, },
entryId: 'id', id: 'id',
} as ImportMap; };
const existingCustomFields: CustomFields = { const existingCustomFields: CustomFields = {
lighting: { label: 'lighting', type: 'text', colour: 'red' }, lighting: { label: 'lighting', type: 'text', colour: 'red' },
@@ -160,3 +162,13 @@ describe('getCustomFieldData()', () => {
}); });
}); });
}); });
describe('rundownToTabular()', () => {
it('returns an array of arrays describing a rundown', () => {
const testRundown = demoDb.rundowns['default'];
const testCustomFields = demoDb.customFields;
const result = rundownToTabular(testRundown, testCustomFields);
expect(result).toMatchSnapshot();
});
});
@@ -0,0 +1 @@
export const EXCEL_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
@@ -3,7 +3,7 @@ import multer, { type FileFilterCallback } from 'multer';
import { storage } from '../../utils/upload.js'; import { storage } from '../../utils/upload.js';
export const EXCEL_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'; import { EXCEL_MIME } from './excel.constants.js';
const filterExcel = (_req: Request, file: Express.Multer.File, cb: FileFilterCallback) => { const filterExcel = (_req: Request, file: Express.Multer.File, cb: FileFilterCallback) => {
if (file.mimetype.includes(EXCEL_MIME)) { if (file.mimetype.includes(EXCEL_MIME)) {
@@ -132,7 +132,7 @@ export const parseExcel = (
entry.timeDanger = parseExcelDate(column); entry.timeDanger = parseExcelDate(column);
} else if (j === indexMap.colour) { } else if (j === indexMap.colour) {
entry.colour = makeString(column, ''); entry.colour = makeString(column, '');
} else if (j === indexMap.entryId) { } else if (j === indexMap.id) {
entry.id = encodeURIComponent(makeString(column, undefined)); entry.id = encodeURIComponent(makeString(column, undefined));
} else if (j in indexMap.custom) { } else if (j in indexMap.custom) {
const importKey = indexMap.custom[j]; const importKey = indexMap.custom[j];
+24 -4
View File
@@ -1,9 +1,14 @@
import express from 'express'; import express from 'express';
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
import { uploadExcel } from './excel.middleware.js';
import { validateFileExists, validateImportMapOptions } from './excel.validation.js';
import { CustomFields, ErrorResponse, Rundown } from 'ontime-types'; import { CustomFields, ErrorResponse, Rundown } from 'ontime-types';
import { generateRundownPreview, listWorksheets, saveExcelFile } from './excel.service.js';
import { getProjectCustomFields } from '../rundown/rundown.dao.js';
import { uploadExcel } from './excel.middleware.js';
import { validateFileExists, validateImportMapOptions, validateRundownExport } from './excel.validation.js';
import { generateExcelFile, generateRundownPreview, listWorksheets, readExcelFile } from './excel.service.js';
import { EXCEL_MIME } from './excel.constants.js';
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
export const router = express.Router(); export const router = express.Router();
@@ -11,7 +16,7 @@ router.post('/upload', uploadExcel, validateFileExists, async (req: Request, res
try { try {
// file has been validated by middleware // file has been validated by middleware
const filePath = (req.file as Express.Multer.File).path; const filePath = (req.file as Express.Multer.File).path;
await saveExcelFile(filePath); await readExcelFile(filePath);
res.status(201).send(); res.status(201).send();
} catch (error) { } catch (error) {
res.status(500).send({ message: String(error) }); res.status(500).send({ message: String(error) });
@@ -40,3 +45,18 @@ router.post(
} }
}, },
); );
router.get('/:rundownId/export', validateRundownExport, (req: Request, res: Response) => {
try {
const rundown = getDataProvider().getRundown(req.params.rundownId);
const customFields = getProjectCustomFields();
const buffer = generateExcelFile(rundown, customFields);
res.setHeader('Content-Disposition', `attachment;`);
res.setHeader('Content-Type', EXCEL_MIME);
res.setHeader('Content-Length', buffer.length.toString());
res.status(200).send(buffer);
} catch (error) {
res.status(500).send({ message: 'Failed to generate Excel file' });
}
});
@@ -18,10 +18,15 @@ import { getProjectCustomFields } from '../rundown/rundown.dao.js';
import { parseCustomFields } from '../custom-fields/customFields.parser.js'; import { parseCustomFields } from '../custom-fields/customFields.parser.js';
import { parseExcel } from './excel.parser.js'; import { parseExcel } from './excel.parser.js';
import { rundownToTabular } from './excel.utils.js';
let excelData: WorkBook = xlsx.utils.book_new(); let excelData: WorkBook = xlsx.utils.book_new();
export async function saveExcelFile(filePath: string) { /**
* Receives and parses an excel file
* The file is deleted after being read
*/
export async function readExcelFile(filePath: string) {
if (!existsSync(filePath)) { if (!existsSync(filePath)) {
throw new Error('Upload of excel file failed'); throw new Error('Upload of excel file failed');
} }
@@ -33,6 +38,9 @@ export async function saveExcelFile(filePath: string) {
await deleteFile(filePath); await deleteFile(filePath);
} }
/**
* List all worksheets in the current spreadsheet file
*/
export function listWorksheets(): string[] { export function listWorksheets(): string[] {
return excelData.SheetNames; return excelData.SheetNames;
} }
@@ -60,3 +68,19 @@ export function generateRundownPreview(options: ImportMap): { rundown: Rundown;
return { rundown, customFields: parsedCustomFields }; return { rundown, customFields: parsedCustomFields };
} }
/**
* Creates an xlsx file from a given rundown and custom fields
* @throws if the rundown is empty
*/
export function generateExcelFile(rundown: Rundown, customFields: CustomFields): Buffer {
if (rundown.order.length === 0) {
throw new Error('Cannot generate an Excel file from an empty rundown');
}
const workbook = xlsx.utils.book_new();
const worksheet = xlsx.utils.aoa_to_sheet(rundownToTabular(rundown, customFields));
xlsx.utils.book_append_sheet(workbook, worksheet, rundown.title || 'Rundown');
return xlsx.write(workbook, { type: 'buffer', bookType: 'xlsx' });
}
+173 -5
View File
@@ -1,5 +1,5 @@
import { CustomFieldKey, CustomFields, MaybeNumber } from 'ontime-types'; import { CustomFieldKey, CustomFields, MaybeNumber, OntimeEntry, Rundown } from 'ontime-types';
import { checkRegex, customFieldLabelToKey, ImportMap } from 'ontime-utils'; import { checkRegex, customFieldLabelToKey, ImportMap, millisToString } from 'ontime-utils';
/** /**
* Receives an import map which contains custom field labels and a custom fields object * Receives an import map which contains custom field labels and a custom fields object
@@ -87,7 +87,7 @@ export function generateImportHandlers(importMap: ImportMap) {
timeDanger: null, timeDanger: null,
endAction: null, endAction: null,
timerType: null, timerType: null,
entryId: null, id: null,
custom: {}, custom: {},
}; };
@@ -155,8 +155,8 @@ export function generateImportHandlers(importMap: ImportMap) {
indexMap.timeDanger = col; indexMap.timeDanger = col;
sheetMetadata.timeDanger = { row, col }; sheetMetadata.timeDanger = { row, col };
}, },
[importMap.entryId]: (row: number, col: number) => { [importMap.id]: (row: number, col: number) => {
indexMap.entryId = col; indexMap.id = col;
sheetMetadata['id'] = { row, col }; // important this will be used in a normal context where the id is not called entryId sheetMetadata['id'] = { row, col }; // important this will be used in a normal context where the id is not called entryId
}, },
custom: (row: number, col: number, columnText: string, ontimeKey: string) => { custom: (row: number, col: number, columnText: string, ontimeKey: string) => {
@@ -167,3 +167,171 @@ export function generateImportHandlers(importMap: ImportMap) {
return { handlers, indexMap, sheetMetadata }; return { handlers, indexMap, sheetMetadata };
} }
const emptyCellValue = '';
/**
* Converts a given rundown to 2D array tabular format
* All fields are converted to their string representation as specified by excel
*/
export function rundownToTabular(rundown: Rundown, customFields: CustomFields): string[][] {
const flatRundown: string[][] = [];
// we reuse the keys from the import map so import <> export works by default
const keys = Object.keys(matchedWithDefaultImportMap) as (keyof typeof matchedWithDefaultImportMap)[];
const customFieldKeys = Object.keys(customFields) as CustomFieldKey[];
// create header row
const headerRow: string[] = [];
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
headerRow.push(matchedWithDefaultImportMap[key]);
}
for (let i = 0; i < customFieldKeys.length; i++) {
const key = customFieldKeys[i];
headerRow.push(customFields[key].label);
}
flatRundown.push(headerRow);
// create table body
let previousEntry: OntimeEntry | undefined;
for (let i = 0; i < rundown.flatOrder.length; i++) {
const entryId = rundown.flatOrder[i];
const entry = rundown.entries[entryId];
if (!entry) continue;
// we opt for not exporting delays as they are only relevant for runtime
if (entry.type === 'delay') continue;
// we need a group-end if:
// - the previous entry was a group and now we have another group (both parents are undefined)
// - the previous entry had a parent and the new entry is a group (parent undefined)
// - the previous entry had a parent and the new one doesnt (parent null)
if (
previousEntry &&
((previousEntry.type === 'group' && entry.type === 'group') ||
// @ts-expect-error -- we safely check if the property is nullish
(previousEntry?.parent !== null && entry?.parent == null))
) {
addGroupEnd();
}
previousEntry = entry;
const entryRow: string[] = [];
// each entry is a row in the table
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
// handle edge cases for group
if (entry.type === 'group') {
if (key === 'duration') {
const groupDuration = entry.targetDuration === null ? '' : parseField('duration', entry.targetDuration);
entryRow.push(groupDuration);
continue;
} else if (key === 'timerType') {
entryRow.push('group');
continue;
}
}
// handle edge cases for milestones
if (entry.type === 'milestone') {
if (key === 'timerType') {
entryRow.push('milestone');
continue;
}
}
// handle edge cases for events
if (entry.type === 'event') {
if (key === 'endAction' && entry.endAction === 'none') {
entryRow.push(emptyCellValue);
continue;
}
}
if (key in entry) {
entryRow.push(parseField(key, entry[key as keyof typeof entry]));
} else {
// we push an empty cell to keep the column positioning
entryRow.push(emptyCellValue);
}
}
// add custom fields
if ('custom' in entry) {
for (let i = 0; i < customFieldKeys.length; i++) {
const customFieldKey = customFieldKeys[i] as CustomFieldKey;
const value = entry.custom?.[customFieldKey] ?? emptyCellValue;
entryRow.push(String(value));
}
}
flatRundown.push(entryRow);
}
// close dangling groups
// - last element as a group: previous entry.parent is undefined
// - last element was in a group: previous entry.parent is string
if (previousEntry && (previousEntry.type === 'group' || previousEntry.parent !== null)) {
addGroupEnd();
}
/**
* Adds a new row with group-end timerType
* Ensures that all columns are filled with the correct type
*/
function addGroupEnd() {
const emptyRow = Array(keys.length).fill(emptyCellValue);
const timerTypeIndex = keys.indexOf('timerType');
emptyRow[timerTypeIndex] = 'group-end';
flatRundown.push(emptyRow);
}
return flatRundown;
}
/**
* Map between the ontime key and the excel column name
* This needs to match match the default import map
* TODO: could we organise these objects to have a single source of truth?
*/
const matchedWithDefaultImportMap = {
id: 'ID',
flag: 'Flag',
cue: 'Cue',
title: 'Title',
colour: 'Colour',
timeStart: 'Time Start',
timeEnd: 'Time End',
duration: 'Duration',
linkStart: 'Link Start',
countToEnd: 'Count to end',
note: 'Note',
timerType: 'Timer Type',
endAction: 'End Action',
timeWarning: 'Warning Time',
timeDanger: 'Danger Time',
skip: 'Skip',
};
/**
* Parses a fields value into a string suitable for excel
*/
function parseField(field: string, data: unknown) {
if (data == null) return emptyCellValue;
if (
field === 'timeStart' ||
field === 'timeEnd' ||
field === 'duration' ||
field === 'timeWarning' ||
field === 'timeDanger'
) {
return millisToString(data as MaybeNumber, { fallback: emptyCellValue });
}
if (typeof data === 'boolean') {
return data ? 'TRUE' : emptyCellValue;
}
return typeof data == 'string' ? data : String(data);
}
@@ -1,6 +1,7 @@
import { isImportMap } from 'ontime-utils'; import { isImportMap } from 'ontime-utils';
import { body } from 'express-validator'; import { body, param } from 'express-validator';
import { import {
requestValidationFunction, requestValidationFunction,
requestValidationFunctionWithFile, requestValidationFunctionWithFile,
@@ -17,3 +18,5 @@ export const validateImportMapOptions = [
requestValidationFunction, requestValidationFunction,
]; ];
export const validateRundownExport = [param('rundownId').isString().trim().notEmpty(), requestValidationFunction];
@@ -1,20 +1,19 @@
import { EndAction, TimerType, isKeyOfType } from 'ontime-types'; import { EndAction, TimeStrategy, TimerType, isKeyOfType } from 'ontime-types';
import { MILLIS_PER_SECOND, maxDuration } from 'ontime-utils'; import { maxDuration } from 'ontime-utils';
import { coerceBoolean, coerceColour, coerceEnum, coerceNumber, coerceString } from '../utils/coerceType.js'; import { coerceBoolean, coerceColour, coerceEnum, coerceNumber, coerceString } from '../utils/coerceType.js';
import { getDataProvider } from '../classes/data-provider/DataProvider.js'; import { getDataProvider } from '../classes/data-provider/DataProvider.js';
/** /**
* *
* @param {number} value time amount in seconds * @param {number} value time amount in milliseconds
* @returns {number} time in milliseconds clamped to 0 and max duration * @returns {number} time in milliseconds clamped to 0 and max duration
*/ */
function clampDuration(value: number): number { function clampDuration(value: number): number {
const valueInMillis = value * MILLIS_PER_SECOND; if (value > maxDuration || value < 0) {
if (valueInMillis > maxDuration || valueInMillis < 0) {
throw new Error('Times should be from 0 to 23:59:59'); throw new Error('Times should be from 0 to 23:59:59');
} }
return valueInMillis; return value;
} }
const propertyConversion = { const propertyConversion = {
@@ -34,6 +33,9 @@ const propertyConversion = {
endAction: (value: unknown) => coerceEnum<EndAction>(value, EndAction), endAction: (value: unknown) => coerceEnum<EndAction>(value, EndAction),
timerType: (value: unknown) => coerceEnum<TimerType>(value, TimerType), timerType: (value: unknown) => coerceEnum<TimerType>(value, TimerType),
linkStart: coerceBoolean,
timeStrategy: (value: unknown) => coerceEnum<TimeStrategy>(value, TimeStrategy),
duration: (value: unknown) => clampDuration(coerceNumber(value)), duration: (value: unknown) => clampDuration(coerceNumber(value)),
timeStart: (value: unknown) => clampDuration(coerceNumber(value)), timeStart: (value: unknown) => clampDuration(coerceNumber(value)),
timeEnd: (value: unknown) => clampDuration(coerceNumber(value)), timeEnd: (value: unknown) => clampDuration(coerceNumber(value)),
@@ -8,6 +8,7 @@ import {
AuthenticationStatus, AuthenticationStatus,
CustomFields, CustomFields,
DatabaseModel, DatabaseModel,
EntryId,
isOntimeEvent, isOntimeEvent,
isOntimeMilestone, isOntimeMilestone,
LogOrigin, LogOrigin,
@@ -360,7 +361,7 @@ export async function upload(sheetId: string, options: ImportMap) {
const rundown = getCurrentRundown(); const rundown = getCurrentRundown();
const sheetOrder: string[] = []; const sheetOrder: string[] = [];
let prevGroup: string | null = null; let prevGroup: EntryId | null = null;
for (const id of rundown.flatOrder) { for (const id of rundown.flatOrder) {
const entry = rundown.entries[id]; const entry = rundown.entries[id];
@@ -418,12 +419,12 @@ export async function upload(sheetId: string, options: ImportMap) {
const isGroupEnd = entryId.startsWith('group-end-'); const isGroupEnd = entryId.startsWith('group-end-');
const id = isGroupEnd ? entryId.split('group-end-')[1] : entryId; const id = isGroupEnd ? entryId.split('group-end-')[1] : entryId;
const entry = isGroupEnd const entry = isGroupEnd
? ({ id: entryId, type: SupportedEntry.Group } as OntimeGroup) ? ({ id: entryId, type: SupportedEntry.Group } as OntimeGroup)
: structuredClone(rundown.entries[id]); : structuredClone(rundown.entries[id]);
updateRundown.push(cellRequestFromEvent(entry, index, worksheetId, sheetMetadata)); updateRundown.push(cellRequestFromEvent(entry, index, worksheetId, sheetMetadata));
}); });
} catch (e) { } catch (e) {
throw new Error(`Sheet write failed to correctly parse rundown: ${e}`) throw new Error(`Sheet write failed to correctly parse rundown: ${e}`);
} }
const writeResponse = await sheets({ version: 'v4', auth: currentAuthClient }).spreadsheets.batchUpdate({ const writeResponse = await sheets({ version: 'v4', auth: currentAuthClient }).spreadsheets.batchUpdate({
+11 -15
View File
@@ -1,11 +1,10 @@
import { expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
// TODO: can we replace the clicks with something else?
test.describe('pages routes are available', () => { test.describe('pages routes are available', () => {
test.use({ viewport: { width: 1920, height: 1080 } }); test.use({ viewport: { width: 1920, height: 1080 } });
test.describe('main views', () => { test.describe('main views', () => {
test('editor', async ({ page }) => { test('editor', async ({ page }) => {
await page.goto('http://localhost:4001/editor'); await page.goto('/editor');
await expect(page).toHaveTitle(/ontime/); await expect(page).toHaveTitle(/ontime/);
await expect(page.getByTestId('editor-container')).toBeVisible(); await expect(page.getByTestId('editor-container')).toBeVisible();
@@ -15,44 +14,41 @@ test.describe('pages routes are available', () => {
}); });
test('cuesheet', async ({ page }) => { test('cuesheet', async ({ page }) => {
await page.goto('http://localhost:4001/cuesheet'); await page.goto('/cuesheet');
await expect(page).toHaveTitle(/ontime/); await expect(page).toHaveTitle(/ontime/);
await page.getByTestId('cuesheet').click(); await expect(page.getByTestId('cuesheet')).toBeVisible();
}); });
test('operator', async ({ page }) => { test('operator', async ({ page }) => {
await page.goto('http://localhost:4001/op'); await page.goto('/op');
await expect(page).toHaveTitle(/ontime/); await expect(page).toHaveTitle(/ontime/);
}); });
test('timer', async ({ page }) => { test('timer', async ({ page }) => {
await page.goto('http://localhost:4001/timer'); await page.goto('/timer');
await expect(page).toHaveTitle(/ontime/); await expect(page).toHaveTitle(/ontime/);
}); });
test('backstage', async ({ page }) => { test('backstage', async ({ page }) => {
await page.goto('http://localhost:4001/backstage'); await page.goto('/backstage');
await expect(page).toHaveTitle(/ontime/); await expect(page).toHaveTitle(/ontime/);
}); });
test('studio', async ({ page }) => { test('studio', async ({ page }) => {
await page.goto('http://localhost:4001/studio'); await page.goto('/studio');
await expect(page).toHaveTitle(/ontime/); await expect(page).toHaveTitle(/ontime/);
}); });
test('countdown', async ({ page }) => { test('countdown', async ({ page }) => {
await page.goto('http://localhost:4001/countdown'); await page.goto('/countdown?sub=32d31');
await expect(page).toHaveTitle(/ontime/); await expect(page).toHaveTitle(/ontime/);
await page.getByRole('button', { name: 'Add' }).click();
await page.getByText('Albania').click();
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.getByText('Albania')).toBeVisible(); await expect(page.getByText('Albania')).toBeVisible();
await expect(page.getByText('Latvia')).toBeHidden(); await expect(page.getByText('Latvia')).toBeHidden();
}); });
@@ -60,15 +56,15 @@ test.describe('pages routes are available', () => {
test.describe('detached views', () => { test.describe('detached views', () => {
test('rundown', async ({ page }) => { test('rundown', async ({ page }) => {
await page.goto('http://localhost:4001/rundown'); await page.goto('/rundown');
await expect(page.getByTestId('panel-rundown')).toBeVisible(); await expect(page.getByTestId('panel-rundown')).toBeVisible();
}); });
test('timer control', async ({ page }) => { test('timer control', async ({ page }) => {
await page.goto('http://localhost:4001/timercontrol'); await page.goto('/timercontrol');
await expect(page.getByTestId('panel-timer-control')).toBeVisible(); await expect(page.getByTestId('panel-timer-control')).toBeVisible();
}); });
test('message control', async ({ page }) => { test('message control', async ({ page }) => {
await page.goto('http://localhost:4001/messagecontrol'); await page.goto('/messagecontrol');
await expect(page.getByTestId('panel-messages-control')).toBeVisible(); await expect(page.getByTestId('panel-messages-control')).toBeVisible();
}); });
}); });
+11 -10
View File
@@ -2,11 +2,16 @@ import { test, expect } from '@playwright/test';
test('smoke test operator', async ({ page }) => { test('smoke test operator', async ({ page }) => {
// make some boilerplate // make some boilerplate
await page.goto('http://localhost:4001/editor'); await page.goto('/editor');
await page.getByRole('button', { name: 'Edit' }).click(); await page.getByRole('button', { name: 'Edit' }).click();
await page.getByRole('button', { name: 'Clear all' }).click(); await page.getByRole('button', { name: 'Clear all' }).click();
await page.getByRole('button', { name: 'Delete all' }).click(); await page.getByRole('button', { name: 'Delete all' }).click();
await page.getByRole('button', { name: 'Create Event' }).click(); await page.getByRole('button', { name: 'Create Group' }).click();
await page.getByTestId('rundown-group').getByTestId('entry__title').click();
await page.getByTestId('rundown-group').getByTestId('entry__title').fill('group 1');
await page.getByTestId('rundown-group').getByTestId('entry__title').press('Enter');
await page.getByRole('button', { name: 'Event', exact: true }).nth(1).click();
await page.getByTestId('time-input-timeStart').fill('1m'); await page.getByTestId('time-input-timeStart').fill('1m');
await page.getByTestId('time-input-timeStart').press('Enter'); await page.getByTestId('time-input-timeStart').press('Enter');
@@ -24,10 +29,9 @@ test('smoke test operator', async ({ page }) => {
await page.getByTestId('entry-3').getByTestId('time-input-duration').fill('1m'); await page.getByTestId('entry-3').getByTestId('time-input-duration').fill('1m');
await page.getByTestId('entry-3').getByTestId('time-input-duration').press('Enter'); await page.getByTestId('entry-3').getByTestId('time-input-duration').press('Enter');
await page.getByRole('button', { name: 'Group' }).nth(1).click();
await page.getByRole('button', { name: 'Edit' }).click(); await page.getByRole('button', { name: 'Edit' }).click();
await page.getByTestId('entry-1').click(); await page.getByTestId('entry-1').click();
await page.getByTestId('editor-container').getByLabel('Cue').fill('--1');
await page.getByRole('button', { name: 'Event', exact: true }).nth(1).click(); await page.getByRole('button', { name: 'Event', exact: true }).nth(1).click();
await page.getByTestId('entry-1').getByTestId('entry__title').click(); await page.getByTestId('entry-1').getByTestId('entry__title').click();
@@ -51,14 +55,11 @@ test('smoke test operator', async ({ page }) => {
// start an event // start an event
await page.getByTestId('panel-timer-control').getByRole('button', { name: 'Start' }).click(); await page.getByTestId('panel-timer-control').getByRole('button', { name: 'Start' }).click();
await page.goto('http://localhost:4001/op'); await page.goto('/op');
await expect(page.getByText('group 1')).toBeInViewport();
await expect(page.getByText('title 1')).toBeInViewport(); await expect(page.getByText('title 1')).toBeInViewport();
await expect(page.getByTestId('--1')).toHaveCSS('opacity', '1'); // BUG: ensure event doesn't inherit the past state of the group
await expect(page.getByText('title 2')).toBeInViewport(); await expect(page.getByText('title 2')).toBeInViewport();
await expect(page.getByText('title 3')).toBeInViewport(); await expect(page.getByText('title 3')).toBeInViewport();
// TODO: this part seems particularly flaky, to revise
// await expect(page.getByText('00:01 - 00:02')).toBeInViewport();
// await expect(page.getByText('00:02 - 00:03')).toBeInViewport();
// await expect(page.getByText('00:03 - 00:04')).toBeInViewport();
}); });
@@ -1,7 +1,5 @@
import { expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
//TODO:
test('show warning when event crosses midnight', async ({ page }) => { test('show warning when event crosses midnight', async ({ page }) => {
await page.goto('http://localhost:4001/editor'); await page.goto('http://localhost:4001/editor');
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, Locator, Page, test } from '@playwright/test'; import { expect, Locator, test } from '@playwright/test';
test('time until absolute', async ({ context }) => { test('time until absolute', async ({ context }) => {
const editor = await context.newPage(); const editor = await context.newPage();
+2 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "ontime", "name": "ontime",
"version": "4.0.0-beta.5", "version": "4.0.0",
"description": "Time keeping for live events", "description": "Time keeping for live events",
"keywords": [ "keywords": [
"ontime", "ontime",
@@ -27,6 +27,7 @@
"typecheck": "turbo run typecheck", "typecheck": "turbo run typecheck",
"build": "turbo run build", "build": "turbo run build",
"build:docker": "cross-env NODE_ENV=docker turbo run build --filter=ontime-server --filter=ontime-ui", "build:docker": "cross-env NODE_ENV=docker turbo run build --filter=ontime-server --filter=ontime-ui",
"build:resolver": "cross-env turbo run build --filter=@getontime/resolver",
"dist-win": "turbo run dist-win", "dist-win": "turbo run dist-win",
"dist-mac": "turbo run dist-mac", "dist-mac": "turbo run dist-mac",
"dist-linux": "turbo run dist-linux", "dist-linux": "turbo run dist-linux",
@@ -1,4 +1,3 @@
import type { AutomationSettings } from './core/Automation.type.js'; import type { AutomationSettings } from './core/Automation.type.js';
import type { CustomFields } from './core/CustomFields.type.js'; import type { CustomFields } from './core/CustomFields.type.js';
import type { ProjectData } from './core/ProjectData.type.js'; import type { ProjectData } from './core/ProjectData.type.js';
@@ -90,7 +90,7 @@ export type OntimeAction =
| { | {
type: 'ontime'; type: 'ontime';
action: OntimeAuxSetAction; action: OntimeAuxSetAction;
time: string; //TODO:(automation set aux) not sure what way around to have the string and where to have the ms value time: string;
} }
| { | {
type: 'ontime'; type: 'ontime';
+1 -1
View File
@@ -14,7 +14,7 @@ export {
type OntimeEvent, type OntimeEvent,
type PlayableEvent, type PlayableEvent,
type TimeField, type TimeField,
SupportedEntry as SupportedEntry, SupportedEntry,
} from './definitions/core/OntimeEntry.js'; } from './definitions/core/OntimeEntry.js';
export type { RundownEntries, Rundown, ProjectRundowns } from './definitions/core/Rundown.type.js'; export type { RundownEntries, Rundown, ProjectRundowns } from './definitions/core/Rundown.type.js';
export { TimeStrategy } from './definitions/TimeStrategy.type.js'; export { TimeStrategy } from './definitions/TimeStrategy.type.js';
@@ -21,7 +21,7 @@ describe('isImportMap()', () => {
timeWarning: 'warning time', timeWarning: 'warning time',
timeDanger: 'danger time', timeDanger: 'danger time',
custom: {}, custom: {},
entryId: 'id', id: 'id',
}; };
expect(isImportMap(importMap)).toBe(true); expect(isImportMap(importMap)).toBe(true);
@@ -72,7 +72,7 @@ describe('isImportMap()', () => {
userDefined: 'userDefined', userDefined: 'userDefined',
anotherOne: 'anotherOne', anotherOne: 'anotherOne',
}, },
entryId: 'id', id: 'id',
}; };
expect(isImportMap(importMap)).toBe(true); expect(isImportMap(importMap)).toBe(true);
@@ -21,7 +21,7 @@ export const defaultImportMap = {
timeWarning: 'warning time', timeWarning: 'warning time',
timeDanger: 'danger time', timeDanger: 'danger time',
custom: {}, custom: {},
entryId: 'id', id: 'id',
}; };
/** /**
-4
View File
@@ -46,10 +46,6 @@
"dependsOn": ["build"], "dependsOn": ["build"],
"outputs": ["dist/**"] "outputs": ["dist/**"]
}, },
"dist-mac:local": {
"dependsOn": ["build"],
"outputs": ["dist/**"]
},
"dist-linux": { "dist-linux": {
"dependsOn": ["build"], "dependsOn": ["build"],
"outputs": ["dist/**"] "outputs": ["dist/**"]