From d7392b93d233b90a3255fab765d032a316b07873 Mon Sep 17 00:00:00 2001 From: Carlos Valente <34649812+cpvalente@users.noreply.github.com> Date: Tue, 27 Feb 2024 13:23:12 +0100 Subject: [PATCH] refactor: remove userFields (#791) * refactor: process custom fields on cache generate * refactor: remove userFields --- apps/client/src/common/api/apiConstants.ts | 1 - apps/client/src/common/api/ontimeApi.ts | 61 +- .../components/tag}/Tag.module.scss | 6 +- .../preview => common/components/tag}/Tag.tsx | 0 .../src/common/hooks-query/useUserFields.ts | 20 - apps/client/src/common/models/UserFields.ts | 14 - .../utils/__tests__/eventsManager.test.ts | 16 +- apps/client/src/common/utils/eventsManager.ts | 18 +- .../utils}/uploadUtils.ts | 62 +- .../app-settings/panel/Panel.module.scss | 15 +- .../app-settings/panel/PanelUtils.tsx | 7 +- .../panel/project-panel/ProjectList.tsx | 2 +- .../panel/project-panel/ProjectListItem.tsx | 2 +- .../project-panel/ProjectPanel.module.scss | 6 +- .../ProjectSettingsPanel.tsx | 4 +- .../panel/sources-panel/GSheetSetup.tsx | 146 ++-- .../panel/sources-panel/ImportMap.tsx | 65 -- .../panel/sources-panel/ImportReview.tsx | 51 +- .../sources-panel/SourcesPanel.module.scss | 5 + .../panel/sources-panel/SourcesPanel.tsx | 129 +++- .../import-map/ImportMapForm.tsx | 200 +++++ .../__test__/importMapUtils.test.ts | 40 + .../import-map/importMapUtils.ts | 65 ++ .../preview/PreviewRundown.module.scss | 12 + .../sources-panel/preview/PreviewRundown.tsx | 126 +++ .../panel/sources-panel/useGoogleSheet.ts | 23 +- .../panel/sources-panel/useSheetStore.ts | 44 +- apps/client/src/features/cuesheet/defaults.ts | 23 +- .../modals/upload-modal/UploadFile.tsx | 7 +- .../modals/upload-modal/UploadModal.tsx | 99 +-- .../upload-modal/preview/PreviewExcel.tsx | 21 - .../upload-modal/preview/PreviewRundown.tsx | 151 ---- .../preview/PreviewTable.module.scss | 68 -- .../upload-modal/upload-entry/UploadEntry.tsx | 4 +- .../upload-options/ExcelFileOptions.tsx | 71 -- .../upload-options/ImportMapTable.module.scss | 30 - .../upload-options/ImportMapTable.tsx | 50 -- .../client/src/features/operator/Operator.tsx | 4 +- .../event-editor/EventEditor.module.scss | 3 + .../rundown/event-editor/EventEditor.tsx | 34 +- .../composite/EventEditorTitles.tsx | 6 +- apps/server/src/app.ts | 3 +- .../src/classes/data-provider/DataProvider.ts | 11 - .../data-provider/DataProvider.utils.ts | 6 +- .../__tests__/DataProvider.test.ts | 52 -- .../integrationController.config.ts | 12 +- .../src/controllers/ontimeController.ts | 45 +- .../controllers/ontimeController.validate.ts | 24 +- .../controllers/sheetController.validate.ts | 15 +- apps/server/src/index.ts | 12 +- apps/server/src/models/dataModel.ts | 12 - apps/server/src/models/eventsDefinition.ts | 10 - apps/server/src/routes/ontimeRouter.ts | 18 +- .../__tests__/rundownCache.test.ts | 120 --- .../services/rundown-service/rundownCache.ts | 9 +- .../services/sheet-service/SheetService.ts | 20 +- .../__tests__/sheetUtils.test.ts | 94 +-- .../server/src/utils/__tests__/parser.test.ts | 726 ++++++++++++------ apps/server/src/utils/parser.ts | 191 ++--- apps/server/src/utils/parserFunctions.ts | 28 - apps/server/test-db/db.json | 191 +---- demo-db/db.json | 194 +---- e2e/tests/002-upload-showfile.spec.ts | 2 +- e2e/tests/fixtures/test-db.json | 194 +---- .../types/src/definitions/DataModel.type.ts | 2 - .../src/definitions/core/OntimeEvent.type.ts | 10 - .../src/definitions/core/UserFields.type.ts | 12 - packages/types/src/index.ts | 3 - packages/utils/index.ts | 14 +- .../__tests__/excelImport.test.ts | 42 - .../__tests__/spreadsheetImport.test.ts | 54 ++ .../spreadsheetImport.ts} | 27 +- packages/utils/src/generic/generic.ts | 6 + 73 files changed, 1580 insertions(+), 2290 deletions(-) rename apps/client/src/{features/modals/upload-modal/preview => common/components/tag}/Tag.module.scss (61%) rename apps/client/src/{features/modals/upload-modal/preview => common/components/tag}/Tag.tsx (100%) delete mode 100644 apps/client/src/common/hooks-query/useUserFields.ts delete mode 100644 apps/client/src/common/models/UserFields.ts rename apps/client/src/{features/modals/upload-modal => common/utils}/uploadUtils.ts (52%) delete mode 100644 apps/client/src/features/app-settings/panel/sources-panel/ImportMap.tsx create mode 100644 apps/client/src/features/app-settings/panel/sources-panel/import-map/ImportMapForm.tsx create mode 100644 apps/client/src/features/app-settings/panel/sources-panel/import-map/__test__/importMapUtils.test.ts create mode 100644 apps/client/src/features/app-settings/panel/sources-panel/import-map/importMapUtils.ts create mode 100644 apps/client/src/features/app-settings/panel/sources-panel/preview/PreviewRundown.module.scss create mode 100644 apps/client/src/features/app-settings/panel/sources-panel/preview/PreviewRundown.tsx delete mode 100644 apps/client/src/features/modals/upload-modal/preview/PreviewExcel.tsx delete mode 100644 apps/client/src/features/modals/upload-modal/preview/PreviewRundown.tsx delete mode 100644 apps/client/src/features/modals/upload-modal/preview/PreviewTable.module.scss delete mode 100644 apps/client/src/features/modals/upload-modal/upload-options/ExcelFileOptions.tsx delete mode 100644 apps/client/src/features/modals/upload-modal/upload-options/ImportMapTable.module.scss delete mode 100644 apps/client/src/features/modals/upload-modal/upload-options/ImportMapTable.tsx delete mode 100644 packages/types/src/definitions/core/UserFields.type.ts delete mode 100644 packages/utils/src/feature/excel-import/__tests__/excelImport.test.ts create mode 100644 packages/utils/src/feature/spreadsheet-import/__tests__/spreadsheetImport.test.ts rename packages/utils/src/feature/{excel-import/excelImport.ts => spreadsheet-import/spreadsheetImport.ts} (52%) create mode 100644 packages/utils/src/generic/generic.ts diff --git a/apps/client/src/common/api/apiConstants.ts b/apps/client/src/common/api/apiConstants.ts index ccd7f5de1..290ccda4c 100644 --- a/apps/client/src/common/api/apiConstants.ts +++ b/apps/client/src/common/api/apiConstants.ts @@ -9,7 +9,6 @@ export const PROJECT_LIST = ['projectList']; export const RUNDOWN = ['rundown']; export const RUNTIME = ['runtimeStore']; export const SHEET_STATE = ['sheetState']; -export const USERFIELDS = ['userFields']; export const CUSTOM_FIELDS = ['customFields']; export const VIEW_SETTINGS = ['viewSettings']; diff --git a/apps/client/src/common/api/ontimeApi.ts b/apps/client/src/common/api/ontimeApi.ts index bb49c0119..cc2da4c7d 100644 --- a/apps/client/src/common/api/ontimeApi.ts +++ b/apps/client/src/common/api/ontimeApi.ts @@ -14,10 +14,9 @@ import { ProjectData, ProjectFileListResponse, Settings, - UserFields, ViewSettings, } from 'ontime-types'; -import { ExcelImportMap } from 'ontime-utils'; +import { ImportMap } from 'ontime-utils'; import { apiRepoLatest } from '../../externals'; import fileDownload from '../utils/fileDownload'; @@ -84,23 +83,6 @@ export async function postAliases(data: Alias[]) { return axios.post(`${ontimeURL}/aliases`, data); } -/** - * @description HTTP request to retrieve user fields - * @return {Promise} - */ -export async function getUserFields(): Promise { - const res = await axios.get(`${ontimeURL}/userfields`); - return res.data; -} - -/** - * @description HTTP request to mutate user fields - * @return {Promise} - */ -export async function postUserFields(data: UserFields) { - return axios.post(`${ontimeURL}/userfields`, data); -} - /** * @description HTTP request to retrieve osc settings * @return {Promise} @@ -189,40 +171,35 @@ export const uploadProjectFile = async ( * @description Make patch changes to the objects in the db * @return {Promise} */ -export async function patchData(patchDb: Partial) { - const response = await axios.patch(`${ontimeURL}/db`, patchDb); - return response; +export async function patchData(patchDb: Partial): Promise { + return await axios.patch(`${ontimeURL}/db`, patchDb); } -type PostPreviewExcelResponse = { +type PreviewSpreadsheetResponse = { rundown: OntimeRundown; - userFields: UserFields; + customFields: CustomFields; }; /** * @description Make patch changes to the objects in the db - * @return {Promise} - returns parsed rundown and userfields + * @return {Promise} - returns parsed rundown and customFields */ -export async function postPreviewExcel(file: File, setProgress: (value: number) => void, options?: ExcelImportMap) { +export async function importSpreadsheetPreview(file: File, options: ImportMap): Promise { const formData = new FormData(); formData.append('userFile', file); formData.append('options', JSON.stringify(options)); - const response: AxiosResponse = await axios.post( - `${ontimeURL}/preview-spreadsheet`, + const response: AxiosResponse = await axios.post( + `${ontimeURL}/spreadsheet/preview`, formData, { headers: { 'Content-Type': 'multipart/form-data', }, - onUploadProgress: (progressEvent) => { - const complete = progressEvent?.total ? Math.round((progressEvent.loaded * 100) / progressEvent.total) : 0; - setProgress(complete); - }, }, ); - return response; + return response.data; } export type HasUpdate = { @@ -303,10 +280,10 @@ export const revokeAuthentication = async (): Promise<{ authenticated: Authentic */ export const previewRundown = async ( sheetId: string, - options: ExcelImportMap, + options: ImportMap, ): Promise<{ rundown: OntimeRundown; - userFields: UserFields; + customFields: CustomFields; }> => { const response = await axios.post(`${ontimeURL}/sheet/${sheetId}/read`, { options }); return response.data; @@ -315,7 +292,7 @@ export const previewRundown = async ( /** * @description HTTP request to upload the rundown to a google sheet */ -export const uploadRundown = async (sheetId: string, options: ExcelImportMap): Promise => { +export const uploadRundown = async (sheetId: string, options: ImportMap): Promise => { const response = await axios.post(`${ontimeURL}/sheet/${sheetId}/write`, { options }); return response.data; }; @@ -371,11 +348,17 @@ export async function createProject( return res.data; } +/** + * Requests list of known custom fields + */ export async function getCustomFields(): Promise { const res = await axios.get(`${projectDataURL}/custom-field`); return res.data; } +/** + * Sets list of known custom fields + */ export async function postCustomField(newField: CustomField): Promise { const res = await axios.post(`${projectDataURL}/custom-field`, { ...newField, @@ -383,6 +366,9 @@ export async function postCustomField(newField: CustomField): Promise { const res = await axios.put(`${projectDataURL}/custom-field/${label}`, { ...newField, @@ -390,6 +376,9 @@ export async function editCustomField(label: CustomFieldLabel, newField: CustomF return res.data; } +/** + * Deletes single custom field + */ export async function deleteCustomField(label: CustomFieldLabel): Promise { const res = await axios.delete(`${projectDataURL}/custom-field/${label}`); return res.data; diff --git a/apps/client/src/features/modals/upload-modal/preview/Tag.module.scss b/apps/client/src/common/components/tag/Tag.module.scss similarity index 61% rename from apps/client/src/features/modals/upload-modal/preview/Tag.module.scss rename to apps/client/src/common/components/tag/Tag.module.scss index 180dd5bb0..7f0d1e631 100644 --- a/apps/client/src/features/modals/upload-modal/preview/Tag.module.scss +++ b/apps/client/src/common/components/tag/Tag.module.scss @@ -1,11 +1,9 @@ .tag { font-size: calc(1rem - 3px); letter-spacing: 0.5px; - background-color: $gray-100; - color: $ui-black; + background-color: $gray-900; + color: $ui-white; border-radius: 2px; padding: 0 0.25rem; white-space: nowrap; - - text-transform: capitalize; } diff --git a/apps/client/src/features/modals/upload-modal/preview/Tag.tsx b/apps/client/src/common/components/tag/Tag.tsx similarity index 100% rename from apps/client/src/features/modals/upload-modal/preview/Tag.tsx rename to apps/client/src/common/components/tag/Tag.tsx diff --git a/apps/client/src/common/hooks-query/useUserFields.ts b/apps/client/src/common/hooks-query/useUserFields.ts deleted file mode 100644 index 56faaae9e..000000000 --- a/apps/client/src/common/hooks-query/useUserFields.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { useQuery } from '@tanstack/react-query'; - -import { queryRefetchInterval } from '../../ontimeConfig'; -import { USERFIELDS } from '../api/apiConstants'; -import { getUserFields } from '../api/ontimeApi'; -import { userFieldsPlaceholder } from '../models/UserFields'; - -export default function useUserFields() { - const { data, status, isFetching, isError, refetch } = useQuery({ - queryKey: USERFIELDS, - queryFn: getUserFields, - placeholderData: userFieldsPlaceholder, - retry: 5, - retryDelay: (attempt) => attempt * 2500, - refetchInterval: queryRefetchInterval, - networkMode: 'always', - }); - - return { data, status, isFetching, isError, refetch }; -} diff --git a/apps/client/src/common/models/UserFields.ts b/apps/client/src/common/models/UserFields.ts deleted file mode 100644 index c2be66f5b..000000000 --- a/apps/client/src/common/models/UserFields.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { UserFields } from 'ontime-types'; - -export const userFieldsPlaceholder: UserFields = { - user0: '', - user1: '', - user2: '', - user3: '', - user4: '', - user5: '', - user6: '', - user7: '', - user8: '', - user9: '', -}; diff --git a/apps/client/src/common/utils/__tests__/eventsManager.test.ts b/apps/client/src/common/utils/__tests__/eventsManager.test.ts index 36bd2bbd8..9379ee308 100644 --- a/apps/client/src/common/utils/__tests__/eventsManager.test.ts +++ b/apps/client/src/common/utils/__tests__/eventsManager.test.ts @@ -1,4 +1,4 @@ -import { EndAction, OntimeEvent, SupportedEvent, TimerType } from 'ontime-types'; +import { EndAction, EventCustomFields, OntimeEvent, SupportedEvent, TimerType } from 'ontime-types'; import { cloneEvent } from '../eventsManager'; @@ -21,18 +21,11 @@ describe('cloneEvent()', () => { skip: false, colour: 'F00', revision: 10, - user0: 'user0', - user1: 'user1', - user2: 'user2', - user3: 'user3', - user4: 'user4', - user5: 'user5', - user6: 'user6', - user7: 'user7', - user8: 'user8', - user9: 'user9', timeWarning: 120000, timeDanger: 60000, + custom: { + lighting: { value: '3' }, + } as EventCustomFields, } as OntimeEvent; const cloned = cloneEvent(original); @@ -55,5 +48,6 @@ describe('cloneEvent()', () => { expect(cloned.revision).toBe(0); expect(cloned.timeWarning).toBe(original.timeWarning); expect(cloned.timeDanger).toBe(original.timeDanger); + expect(cloned.custom).toStrictEqual({}); }); }); diff --git a/apps/client/src/common/utils/eventsManager.ts b/apps/client/src/common/utils/eventsManager.ts index eac46c0dc..f90f01431 100644 --- a/apps/client/src/common/utils/eventsManager.ts +++ b/apps/client/src/common/utils/eventsManager.ts @@ -6,22 +6,7 @@ import { OntimeEvent, SupportedEvent } from 'ontime-types'; * @param {string} [after] * @return {OntimeEvent} clean event */ -type ClonedEvent = Omit< - OntimeEvent, - | 'id' - | 'cue' - | 'user0' - | 'user1' - | 'user2' - | 'user3' - | 'user4' - | 'user5' - | 'user6' - | 'user7' - | 'user8' - | 'user9' - | 'custom' ->; +type ClonedEvent = Omit; export const cloneEvent = (event: OntimeEvent, after?: string): ClonedEvent => { return { type: SupportedEvent.Event, @@ -43,5 +28,6 @@ export const cloneEvent = (event: OntimeEvent, after?: string): ClonedEvent => { revision: 0, timeWarning: event.timeWarning, timeDanger: event.timeDanger, + custom: {}, }; }; diff --git a/apps/client/src/features/modals/upload-modal/uploadUtils.ts b/apps/client/src/common/utils/uploadUtils.ts similarity index 52% rename from apps/client/src/features/modals/upload-modal/uploadUtils.ts rename to apps/client/src/common/utils/uploadUtils.ts index 1bbeb962d..a04f064f3 100644 --- a/apps/client/src/features/modals/upload-modal/uploadUtils.ts +++ b/apps/client/src/common/utils/uploadUtils.ts @@ -1,6 +1,42 @@ -import { ExcelImportMap } from 'ontime-utils'; +/** + * Collection of rules for pre-validating a spreadsheet + * @param file + */ +export function validateSpreadsheetImport(file: File) { + if (!isExcelFile(file)) { + throw new Error('Unknown file type'); + } -import { ProjectFileImportOptions } from '../../../common/api/ontimeApi'; + // Check if file is empty + if (file.size === 0) { + throw new Error('File is empty'); + } + + // Limit file size of an excel file to around 10MB + if (file.size > 10_000_000) { + throw new Error('File size limit (10MB) exceeded'); + } +} + +/** + * Collection of rules for pre-validating a project file + * @param file + */ +export function validateProjectFile(file: File) { + if (!isOntimeFile(file)) { + throw new Error('Unknown file type'); + } + + // Check if file is empty + if (file.size === 0) { + throw new Error('File is empty'); + } + + // Limit file size of a project file to around 1MB + if (file.size > 1_000_000) { + throw new Error('File size limit (10MB) exceeded'); + } +} /** * Validates a file according to the app upload contract @@ -40,25 +76,3 @@ export function isExcelFile(file: File | null) { export function isOntimeFile(file: File | null) { return file?.name.endsWith('.json'); } - -type PersistedOntimeOptions = { - optionType: 'ontime'; - options: Partial; -}; - -type PersistedExcelOptions = { - optionType: 'excel'; - options: ExcelImportMap; -}; - -export function persistOptions(options: PersistedOntimeOptions | PersistedExcelOptions) { - localStorage.setItem(`ontime-import-options-${options.optionType}`, JSON.stringify(options.options)); -} - -export function getPersistedOptions(optionType: 'excel' | 'ontime') { - const options = localStorage.getItem(`ontime-import-options-${optionType}`); - if (!options) { - return null; - } - return JSON.parse(options); -} diff --git a/apps/client/src/features/app-settings/panel/Panel.module.scss b/apps/client/src/features/app-settings/panel/Panel.module.scss index a126c0cf6..22030e8a3 100644 --- a/apps/client/src/features/app-settings/panel/Panel.module.scss +++ b/apps/client/src/features/app-settings/panel/Panel.module.scss @@ -30,6 +30,9 @@ $inner-padding: 1rem; margin-top: 2rem; font-size: calc(1rem - 1px); max-width: 800px; + display: flex; + flex-direction: column; + gap: 1rem; } .paragraph { @@ -60,15 +63,25 @@ $inner-padding: 1rem; border-collapse: collapse; font-size: calc(1rem - 2px); text-align: left; + margin-bottom: 2rem; + + thead { + position: sticky; + top: 0; + z-index: 3; + box-shadow: 0 1px $white-10; + } tr { padding: 1rem 0; } th { - border-bottom: 1px solid $white-10; font-weight: 400; color: $gray-400; + background-color: $gray-1350; + white-space: nowrap; + text-transform: capitalize; } th, diff --git a/apps/client/src/features/app-settings/panel/PanelUtils.tsx b/apps/client/src/features/app-settings/panel/PanelUtils.tsx index fdbcffea0..df449b165 100644 --- a/apps/client/src/features/app-settings/panel/PanelUtils.tsx +++ b/apps/client/src/features/app-settings/panel/PanelUtils.tsx @@ -1,5 +1,7 @@ import { HTMLAttributes, ReactNode } from 'react'; +import { cx } from '../../../common/utils/styleUtils'; + import style from './Panel.module.scss'; export function Header({ children }: { children: ReactNode }) { @@ -41,10 +43,11 @@ export function Card({ children, ...props }: { children: ReactNode } & JSX.Intri ); } -export function Table({ children }: { children: ReactNode }) { +export function Table({ className, children }: { className?: string; children: ReactNode }) { + const classes = cx([style.table, className]); return (
- {children}
+ {children}
); } diff --git a/apps/client/src/features/app-settings/panel/project-panel/ProjectList.tsx b/apps/client/src/features/app-settings/panel/project-panel/ProjectList.tsx index 7c22d59b2..9fefad91c 100644 --- a/apps/client/src/features/app-settings/panel/project-panel/ProjectList.tsx +++ b/apps/client/src/features/app-settings/panel/project-panel/ProjectList.tsx @@ -72,7 +72,7 @@ export default function ProjectList({ isCreatingProject, onToggleCreate }: Proje - Project Name + Project Name Date Created Date Modified diff --git a/apps/client/src/features/app-settings/panel/project-panel/ProjectListItem.tsx b/apps/client/src/features/app-settings/panel/project-panel/ProjectListItem.tsx index b378b4c24..0c1ed9d8f 100644 --- a/apps/client/src/features/app-settings/panel/project-panel/ProjectListItem.tsx +++ b/apps/client/src/features/app-settings/panel/project-panel/ProjectListItem.tsx @@ -100,7 +100,7 @@ export default function ProjectListItem({ ) : ( <> - {filename} + {filename} {new Date(createdAt).toLocaleString()} {new Date(updatedAt).toLocaleString()} diff --git a/apps/client/src/features/app-settings/panel/project-panel/ProjectPanel.module.scss b/apps/client/src/features/app-settings/panel/project-panel/ProjectPanel.module.scss index 5ba44e13c..fa41d46c5 100644 --- a/apps/client/src/features/app-settings/panel/project-panel/ProjectPanel.module.scss +++ b/apps/client/src/features/app-settings/panel/project-panel/ProjectPanel.module.scss @@ -54,4 +54,8 @@ font-size: calc(1rem - 2px); font-weight: 400; color: $red-500; -} \ No newline at end of file +} + +.containCell { + max-width: 400px; +} diff --git a/apps/client/src/features/app-settings/panel/project-settings-panel/ProjectSettingsPanel.tsx b/apps/client/src/features/app-settings/panel/project-settings-panel/ProjectSettingsPanel.tsx index 76c6c9aac..bd8f5ff91 100644 --- a/apps/client/src/features/app-settings/panel/project-settings-panel/ProjectSettingsPanel.tsx +++ b/apps/client/src/features/app-settings/panel/project-settings-panel/ProjectSettingsPanel.tsx @@ -11,7 +11,7 @@ import * as Panel from '../PanelUtils'; import CustomFieldEntry from './CustomFieldEntry'; import CustomFieldForm from './CustomFieldForm'; -const userFieldsDocsUrl = 'https://ontime.gitbook.io/v2/features/user-fields'; +const customFieldsDocsUrl = 'https://ontime.gitbook.io/v2/features/user-fields'; export default function ProjectSettingsPanel() { const { data, refetch } = useCustomFields(); @@ -64,7 +64,7 @@ export default function ProjectSettingsPanel() {

This data is not used by Ontime. - See the docs + See the docs diff --git a/apps/client/src/features/app-settings/panel/sources-panel/GSheetSetup.tsx b/apps/client/src/features/app-settings/panel/sources-panel/GSheetSetup.tsx index dddab0275..bdc888770 100644 --- a/apps/client/src/features/app-settings/panel/sources-panel/GSheetSetup.tsx +++ b/apps/client/src/features/app-settings/panel/sources-panel/GSheetSetup.tsx @@ -16,7 +16,9 @@ interface GSheetSetupProps { onCancel: () => void; } -export default function GSheetSetup({ onCancel }: GSheetSetupProps) { +export default function GSheetSetup(props: GSheetSetupProps) { + const { onCancel } = props; + const { revoke, connect, verifyAuth } = useGoogleSheet(); const [file, setFile] = useState(null); const [authKey, setAuthKey] = useState(null); @@ -34,6 +36,10 @@ export default function GSheetSetup({ onCancel }: GSheetSetupProps) { const result = await verifyAuth(); if (result) { setAuthenticationStatus(result.authenticated); + // if we are still pending, lets check again in 2seconds + if (result.authenticated === 'pending') { + setTimeout(getAuthStatus, 2000); + } } }; @@ -42,11 +48,6 @@ export default function GSheetSetup({ onCancel }: GSheetSetupProps) { getAuthStatus(); }, []); - const handleCancelFlow = () => { - revoke(); - onCancel(); - }; - // user cancels the flow const handleRevoke = async () => { setLoading('cancel'); @@ -55,6 +56,11 @@ export default function GSheetSetup({ onCancel }: GSheetSetupProps) { setLoading(''); }; + const handleCancelFlow = async () => { + await handleRevoke(); + onCancel(); + }; + /** * Gets file from input * @param event @@ -104,85 +110,79 @@ export default function GSheetSetup({ onCancel }: GSheetSetupProps) { const canAuthenticate = Boolean(authKey) && Boolean(authLink); const isLoading = Boolean(loading); const isAuthenticated = authenticationStatus === 'authenticated'; + const isAuthenticating = authenticationStatus === 'pending'; return ( Sync with Google Sheet (experimental) - - - {isAuthenticated ? ( - - Authenticated + {isAuthenticated ? ( + ) : ( + + )} + + + Upload Client Secret provided by Google + {undefined} + + + + Enter ID of sheet to synchronise + {undefined} + setSheetId(event.target.value)} + isDisabled={isLoading || canAuthenticate} + /> + + {!canAuthenticate ? ( + +
+ +
) : ( - <> - - Upload Client Secret provided by Google - {undefined} - +
+ + {authKey ? authKey : 'Upload files to generate Auth Key'} + + -
-
- ) : ( - -
- - {authKey ? authKey : 'Upload files to generate Auth Key'} - - -
-
- )} - + leftIcon={} + onClick={handleAuthenticate} + isDisabled={!canAuthenticate || isLoading} + isLoading={loading === 'authenticate' || isAuthenticating} + > + Authenticate + + +
)}
); diff --git a/apps/client/src/features/app-settings/panel/sources-panel/ImportMap.tsx b/apps/client/src/features/app-settings/panel/sources-panel/ImportMap.tsx deleted file mode 100644 index 1b5ee8026..000000000 --- a/apps/client/src/features/app-settings/panel/sources-panel/ImportMap.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { useState } from 'react'; -import { Button } from '@chakra-ui/react'; - -import ExcelFileOptions from '../../../modals/upload-modal/upload-options/ExcelFileOptions'; -import * as Panel from '../PanelUtils'; - -import useGoogleSheet from './useGoogleSheet'; -import { useSheetStore } from './useSheetStore'; - -import style from './SourcesPanel.module.scss'; - -export default function ImportMap() { - const { importRundownPreview, exportRundown } = useGoogleSheet(); - - const importOptions = useSheetStore((state) => state.excelFileOptions); - const patchImportOptions = useSheetStore((state) => state.patchExcelFileOptions); - const stepData = useSheetStore((state) => state.stepData); - const sheetId = useSheetStore((state) => state.sheetId); - - const [loading, setLoading] = useState<'' | 'export' | 'import'>(''); - - const handleExport = async () => { - if (!sheetId) return; - setLoading('export'); - await exportRundown(sheetId, importOptions); - setLoading(''); - }; - - const handleImportPreview = async () => { - if (!sheetId) return; - setLoading('import'); - await importRundownPreview(sheetId, importOptions); - setLoading(''); - }; - - const isLoading = Boolean(loading); - - return ( - - Import options - - {stepData.worksheet.error} -
- - -
-
- ); -} diff --git a/apps/client/src/features/app-settings/panel/sources-panel/ImportReview.tsx b/apps/client/src/features/app-settings/panel/sources-panel/ImportReview.tsx index e6bdd5f62..53ba7c586 100644 --- a/apps/client/src/features/app-settings/panel/sources-panel/ImportReview.tsx +++ b/apps/client/src/features/app-settings/panel/sources-panel/ImportReview.tsx @@ -1,8 +1,10 @@ +import { useState } from 'react'; import { Button } from '@chakra-ui/react'; -import { OntimeRundown, UserFields } from 'ontime-types'; +import { CustomFields, OntimeRundown } from 'ontime-types'; -import PreviewExcel from '../../../modals/upload-modal/preview/PreviewExcel'; +import * as Panel from '../PanelUtils'; +import PreviewSpreadsheet from './preview/PreviewRundown'; import useGoogleSheet from './useGoogleSheet'; import { useSheetStore } from './useSheetStore'; @@ -10,28 +12,43 @@ import style from './SourcesPanel.module.scss'; interface ImportReviewProps { rundown: OntimeRundown; - userFields: UserFields; + customFields: CustomFields; + onFinished: () => void; } -export default function ImportReview({ rundown, userFields }: ImportReviewProps) { +export default function ImportReview(props: ImportReviewProps) { + const { rundown, customFields, onFinished } = props; + + const [loading, setLoading] = useState(false); const { importRundown } = useGoogleSheet(); const resetPreview = useSheetStore((state) => state.resetPreview); - const applyImport = () => { - importRundown(rundown, userFields); + const handleCancel = () => { + resetPreview(); + onFinished(); + }; + + const applyImport = async () => { + setLoading(true); + await importRundown(rundown, customFields); + setLoading(false); + onFinished(); }; return ( - <> - -
- - -
- + + + Review Rundown +
+ + +
+
+ +
); } diff --git a/apps/client/src/features/app-settings/panel/sources-panel/SourcesPanel.module.scss b/apps/client/src/features/app-settings/panel/sources-panel/SourcesPanel.module.scss index 5aa0ed68f..1412ec74c 100644 --- a/apps/client/src/features/app-settings/panel/sources-panel/SourcesPanel.module.scss +++ b/apps/client/src/features/app-settings/panel/sources-panel/SourcesPanel.module.scss @@ -20,3 +20,8 @@ .inputContainer { flex: 1; } + +.singleActionCell { + width: 50px; + text-align: center; +} diff --git a/apps/client/src/features/app-settings/panel/sources-panel/SourcesPanel.tsx b/apps/client/src/features/app-settings/panel/sources-panel/SourcesPanel.tsx index b7ba61e84..650d6c47d 100644 --- a/apps/client/src/features/app-settings/panel/sources-panel/SourcesPanel.tsx +++ b/apps/client/src/features/app-settings/panel/sources-panel/SourcesPanel.tsx @@ -1,35 +1,61 @@ -import { useRef, useState } from 'react'; +import { ChangeEvent, useRef, useState } from 'react'; import { Button, Input } from '@chakra-ui/react'; import { IoCloudOutline } from '@react-icons/all-files/io5/IoCloudOutline'; import { IoDownloadOutline } from '@react-icons/all-files/io5/IoDownloadOutline'; +import { ImportMap, unpackError } from 'ontime-utils'; +import { maybeAxiosError } from '../../../../common/api/apiUtils'; +import { importSpreadsheetPreview } from '../../../../common/api/ontimeApi'; +import { validateSpreadsheetImport } from '../../../../common/utils/uploadUtils'; import * as Panel from '../PanelUtils'; +import ImportMapForm from './import-map/ImportMapForm'; import GSheetInfo from './GSheetInfo'; import GSheetSetup from './GSheetSetup'; -import ImportMap from './ImportMap'; import ImportReview from './ImportReview'; +import useGoogleSheet from './useGoogleSheet'; import { useSheetStore } from './useSheetStore'; import style from './SourcesPanel.module.scss'; export default function SourcesPanel() { const [importFlow, setImportFlow] = useState<'none' | 'excel' | 'gsheet'>('none'); + const [error, setError] = useState(''); + const { exportRundown, importRundownPreview, revoke, verifyAuth } = useGoogleSheet(); + + const spreadsheet = useSheetStore((state) => state.spreadsheet); + const setSpreadsheet = useSheetStore((state) => state.setSpreadsheet); const authenticationStatus = useSheetStore((state) => state.authenticationStatus); + const setAuthenticationStatus = useSheetStore((state) => state.setAuthenticationStatus); const rundown = useSheetStore((state) => state.rundown); - const userFields = useSheetStore((state) => state.userFields); - - const isAuthenticated = authenticationStatus === 'authenticated'; - const hasData = rundown && userFields; + const setRundown = useSheetStore((state) => state.setRundown); + const customFields = useSheetStore((state) => state.customFields); + const setCustomFields = useSheetStore((state) => state.setCustomFields); + const sheetId = useSheetStore((state) => state.sheetId); const fileInputRef = useRef(null); - const handleFile = () => console.error('not yet implemented'); + const handleFile = (event: ChangeEvent) => { + const fileToUpload = event.target.files?.[0]; + + if (!fileToUpload) { + setSpreadsheet(null); + return; + } + try { + validateSpreadsheetImport(fileToUpload); + setSpreadsheet(fileToUpload); + setImportFlow('excel'); + } catch (error) { + const errorMessage = unpackError(error); + setError(`Error uploading file: ${errorMessage}`); + setSpreadsheet(null); + } + }; const handleUpload = () => { fileInputRef.current?.click(); - setImportFlow('excel'); }; const openGSheetFlow = () => { @@ -40,28 +66,81 @@ export default function SourcesPanel() { setImportFlow('none'); }; + const handleSubmitImportPreview = async (importMap: ImportMap) => { + if (importFlow === 'excel') { + if (!spreadsheet) return; + try { + const previewData = await importSpreadsheetPreview(spreadsheet, importMap); + setRundown(previewData.rundown); + setCustomFields(previewData.customFields); + } catch (error) { + setError(maybeAxiosError(error)); + } + } + + if (importFlow === 'gsheet') { + if (!sheetId) return; + await importRundownPreview(sheetId, importMap); + } + }; + + const cancelImportMap = async () => { + setImportFlow('none'); + if (spreadsheet) { + setSpreadsheet(null); + } + + if (authenticationStatus === 'authenticated') { + await revoke(); + const result = await verifyAuth(); + if (result) { + setAuthenticationStatus(result.authenticated); + } + } + }; + const handleFinished = () => { + setImportFlow('none'); + setRundown(null); + setSpreadsheet(null); + setCustomFields(null); + }; + + const handleSubmitExport = async (importMap: ImportMap) => { + if (!sheetId) return; + await exportRundown(sheetId, importMap); + }; + const isExcelFlow = importFlow === 'excel'; const isGSheetFlow = importFlow === 'gsheet'; + const hasFile = Boolean(spreadsheet); + const isAuthenticated = authenticationStatus === 'authenticated'; + const showInput = importFlow === 'none'; + const showAuth = isGSheetFlow && !isAuthenticated; + const showImportMap = (isGSheetFlow && isAuthenticated) || (isExcelFlow && hasFile); + const showReview = rundown !== null && customFields !== null; + console.log(isAuthenticated); return ( <> Data sources Synchronise your rundown with an external source - - {!isGSheetFlow && ( + {error && {error}} + {showInput && ( <> - + +
- Accepts .xlsx files @@ -75,10 +154,16 @@ export default function SourcesPanel() {
)} - {isGSheetFlow && } - {isExcelFlow && Not yet implemented} - {isAuthenticated && } - {hasData && } + {showAuth && } + {showImportMap && !showReview && ( + + )} + {showReview && } diff --git a/apps/client/src/features/app-settings/panel/sources-panel/import-map/ImportMapForm.tsx b/apps/client/src/features/app-settings/panel/sources-panel/import-map/ImportMapForm.tsx new file mode 100644 index 000000000..c0fd7265a --- /dev/null +++ b/apps/client/src/features/app-settings/panel/sources-panel/import-map/ImportMapForm.tsx @@ -0,0 +1,200 @@ +import { useState } from 'react'; +import { useFieldArray, useForm } from 'react-hook-form'; +import { Button, IconButton, Input } from '@chakra-ui/react'; +import { IoAdd } from '@react-icons/all-files/io5/IoAdd'; +import { IoTrash } from '@react-icons/all-files/io5/IoTrash'; +import { ImportMap } from 'ontime-utils'; + +import { isAlphanumeric } from '../../../../../common/utils/regex'; +import * as Panel from '../../PanelUtils'; +import { useSheetStore } from '../useSheetStore'; + +import { convertToImportMap, getPersistedOptions, NamedImportMap, persistImportMap } from './importMapUtils'; + +import style from '../SourcesPanel.module.scss'; + +interface ImportMapFormProps { + isSpreadsheet?: boolean; + onCancel: () => void; + onSubmitExport: (importMap: ImportMap) => Promise; + onSubmitImport: (importMap: ImportMap) => Promise; +} + +export default function ImportMapForm(props: ImportMapFormProps) { + const { isSpreadsheet, onCancel, onSubmitExport, onSubmitImport } = props; + const namedImportMap = getPersistedOptions(); + + const { + control, + handleSubmit, + register, + formState: { errors, isValid }, + } = useForm({ + mode: 'onBlur', + defaultValues: namedImportMap, + values: namedImportMap, + }); + + const { fields, append, remove } = useFieldArray({ + control, + name: 'custom', + }); + + const stepData = useSheetStore((state) => state.stepData); + + const [loading, setLoading] = useState<'' | 'export' | 'import'>(''); + + const handleExport = async (values: NamedImportMap) => { + setLoading('export'); + const importMap = convertToImportMap(values); + + await onSubmitExport(importMap); + setLoading(''); + }; + + const handleImportPreview = async (values: NamedImportMap) => { + setLoading('import'); + const importMap = convertToImportMap(values); + persistImportMap(values); + await onSubmitImport(importMap); + setLoading(''); + }; + + const deleteCustomImport = (index: number) => { + remove(index); + }; + + const addCustomImport = () => { + append({}); + }; + + const isLoading = Boolean(loading); + const canSubmitSpreadsheet = isSpreadsheet && !isLoading; + const canSubmitGSheet = !isLoading; + const canSubmit = isValid && (canSubmitSpreadsheet || canSubmitGSheet); + + return ( + + + Import options +
+ + {!isSpreadsheet && ( + + )} + +
+
+ + + + Ontime field + From spreadsheet name + + + + + {Object.entries(namedImportMap).map(([label, importName]) => { + if (label === 'custom') { + return null; + } + return ( + + {label} + + + + + + ); + })} + {fields.map((field, index) => { + const ontimeName = field.ontimeName; + const importName = field.importName; + const maybeOntimeError = errors.custom?.[index]?.ontimeName?.message; + const key = `custom.${index}.ontimeName`; + return ( + + + + {maybeOntimeError && {maybeOntimeError}} + + + + + + } + aria-label='Delete entry' + onClick={() => deleteCustomImport(index)} + /> + + + ); + })} + + + + + + + + + + {stepData.worksheet.error} +
+ ); +} diff --git a/apps/client/src/features/app-settings/panel/sources-panel/import-map/__test__/importMapUtils.test.ts b/apps/client/src/features/app-settings/panel/sources-panel/import-map/__test__/importMapUtils.test.ts new file mode 100644 index 000000000..c94fc2abe --- /dev/null +++ b/apps/client/src/features/app-settings/panel/sources-panel/import-map/__test__/importMapUtils.test.ts @@ -0,0 +1,40 @@ +import { ImportCustom } from 'ontime-utils'; + +import { convertToImportMap } from '../importMapUtils'; + +describe('convertToImportMap', () => { + it('converts a namedImportMap to a importMap', () => { + const defaultNamedImporMap = { + Worksheet: 'event schedule', + Start: 'time start', + End: 'time end', + Duration: 'duration', + Cue: 'cue', + Title: 'title', + Presenter: 'presenter', + Subtitle: 'subtitle', + 'Is Public': 'public', + Skip: 'skip', + Note: 'notes', + Colour: 'colour', + 'End action': 'end action', + 'Timer type': 'timer type', + 'Time warning': 'warning time', + 'Time danger': 'danger time', + custom: [ + { ontimeName: 'Custom1 ', importName: 'custom1' }, + { ontimeName: 'Custom2', importName: 'custom2' }, + { ontimeName: 'Custom3', importName: 'custom3' }, + { ontimeName: 'EmptyImportName', importName: '' }, + { ontimeName: '', importName: 'EmptyOntimeName' }, + ] as ImportCustom[], + }; + + const importMap = convertToImportMap(defaultNamedImporMap); + expect(importMap.custom).toStrictEqual({ + Custom1: 'custom1', + Custom2: 'custom2', + Custom3: 'custom3', + }); + }); +}); diff --git a/apps/client/src/features/app-settings/panel/sources-panel/import-map/importMapUtils.ts b/apps/client/src/features/app-settings/panel/sources-panel/import-map/importMapUtils.ts new file mode 100644 index 000000000..54dee8af9 --- /dev/null +++ b/apps/client/src/features/app-settings/panel/sources-panel/import-map/importMapUtils.ts @@ -0,0 +1,65 @@ +import { ImportCustom, ImportMap } from 'ontime-utils'; + +export type NamedImportMap = typeof namedImportMap; + +// Record of label and import name +export const namedImportMap = { + Worksheet: 'event schedule', + Start: 'time start', + End: 'time end', + Duration: 'duration', + Cue: 'cue', + Title: 'title', + Presenter: 'presenter', + Subtitle: 'subtitle', + 'Is Public': 'public', + Skip: 'skip', + Note: 'notes', + Colour: 'colour', + 'End action': 'end action', + 'Timer type': 'timer type', + 'Time warning': 'warning time', + 'Time danger': 'danger time', + custom: [] as ImportCustom[], +}; + +export function convertToImportMap(namedImportMap: NamedImportMap): ImportMap { + const custom = namedImportMap.custom.reduce((accumulator, { ontimeName, importName }) => { + if (ontimeName && importName) { + accumulator[ontimeName.trim()] = importName.trim(); + } + return accumulator; + }, {}); + + return { + worksheet: namedImportMap.Worksheet, + timeStart: namedImportMap.Start, + timeEnd: namedImportMap.End, + duration: namedImportMap.Duration, + cue: namedImportMap.Cue, + title: namedImportMap.Title, + presenter: namedImportMap.Presenter, + subtitle: namedImportMap.Subtitle, + isPublic: namedImportMap['Is Public'], + skip: namedImportMap.Skip, + note: namedImportMap.Note, + colour: namedImportMap.Colour, + endAction: namedImportMap['End action'], + timerType: namedImportMap['Timer type'], + timeWarning: namedImportMap['Time warning'], + timeDanger: namedImportMap['Time danger'], + custom, + }; +} + +export function persistImportMap(options: NamedImportMap) { + localStorage.setItem('ontime-import-options', JSON.stringify(options)); +} + +export function getPersistedOptions(): NamedImportMap { + const options = localStorage.getItem('ontime-import-options'); + if (!options) { + return namedImportMap; + } + return JSON.parse(options); +} diff --git a/apps/client/src/features/app-settings/panel/sources-panel/preview/PreviewRundown.module.scss b/apps/client/src/features/app-settings/panel/sources-panel/preview/PreviewRundown.module.scss new file mode 100644 index 000000000..4f494f378 --- /dev/null +++ b/apps/client/src/features/app-settings/panel/sources-panel/preview/PreviewRundown.module.scss @@ -0,0 +1,12 @@ + .center { + text-align: center; + } + + .nowrap { + white-space: nowrap; + } + + tr .secondaryRow { + background-color: $white-7; + padding-left: 2em; + } diff --git a/apps/client/src/features/app-settings/panel/sources-panel/preview/PreviewRundown.tsx b/apps/client/src/features/app-settings/panel/sources-panel/preview/PreviewRundown.tsx new file mode 100644 index 000000000..bd7a52c59 --- /dev/null +++ b/apps/client/src/features/app-settings/panel/sources-panel/preview/PreviewRundown.tsx @@ -0,0 +1,126 @@ +import { Fragment } from 'react'; +import { CustomFields, isOntimeBlock, isOntimeEvent, OntimeRundown } from 'ontime-types'; +import { millisToString } from 'ontime-utils'; + +import Tag from '../../../../../common/components/tag/Tag'; +import { getAccessibleColour } from '../../../../../common/utils/styleUtils'; +import * as Panel from '../../PanelUtils'; + +import style from './PreviewRundown.module.scss'; + +interface PreviewRundownProps { + rundown: OntimeRundown; + customFields: CustomFields; +} + +function booleanToText(value?: boolean) { + return value ? 'Yes' : undefined; +} + +export default function PreviewRundown(props: PreviewRundownProps) { + const { rundown, customFields } = props; + + // we only count Ontime Events which are 1 based in client + let eventIndex = 0; + + const fieldHeaders = Object.keys(customFields); + + return ( + + + + # + Type + Cue + Title + Subtitle + Presenter + Time Start + Time End + Duration + Warning Time + Danger Time + Is Public + Skip + Colour + Timer Type + End Action + {fieldHeaders.map((field) => ( + {field} + ))} + + + + {rundown.map((event) => { + if (isOntimeBlock(event)) { + return ( + + + - + + + {event.type} + + + {event.title} + + ); + } + if (!isOntimeEvent(event)) { + return null; + } + eventIndex += 1; + const colour = event.colour ? getAccessibleColour(event.colour) : {}; + const isPublic = booleanToText(event.isPublic); + const skip = booleanToText(event.skip); + + return ( + + + + {eventIndex} + + + {event.type} + + {event.cue} + {event.title} + {event.subtitle} + {event.presenter} + {millisToString(event.timeStart)} + {millisToString(event.timeEnd)} + {millisToString(event.duration)} + {millisToString(event.timeWarning)} + {millisToString(event.timeDanger)} + {isPublic && {isPublic}} + {skip && {skip}} + {event.colour} + + {event.timerType} + + + {event.endAction} + + {isOntimeEvent(event) && + fieldHeaders.map((field) => { + let value = ''; + if (field in event.custom) { + value = event.custom[field].value; + } + return {value}; + })} + + {event.note && ( + + + Note: {event.note} + + + )} + + ); + })} + + + ); +} diff --git a/apps/client/src/features/app-settings/panel/sources-panel/useGoogleSheet.ts b/apps/client/src/features/app-settings/panel/sources-panel/useGoogleSheet.ts index cf2f15d45..22586e8d1 100644 --- a/apps/client/src/features/app-settings/panel/sources-panel/useGoogleSheet.ts +++ b/apps/client/src/features/app-settings/panel/sources-panel/useGoogleSheet.ts @@ -1,8 +1,8 @@ import { useQueryClient } from '@tanstack/react-query'; -import { AuthenticationStatus, OntimeRundown, UserFields } from 'ontime-types'; -import { ExcelImportMap } from 'ontime-utils'; +import { AuthenticationStatus, CustomFields, OntimeRundown } from 'ontime-types'; +import { ImportMap } from 'ontime-utils'; -import { RUNDOWN, USERFIELDS } from '../../../../common/api/apiConstants'; +import { CUSTOM_FIELDS, RUNDOWN } from '../../../../common/api/apiConstants'; import { maybeAxiosError } from '../../../../common/api/apiUtils'; import { patchData, @@ -20,7 +20,7 @@ export default function useGoogleSheet() { // functions push data to store const patchStepData = useSheetStore((state) => state.patchStepData); const setRundown = useSheetStore((state) => state.setRundown); - const setUserFields = useSheetStore((state) => state.setUserFields); + const setCustomFields = useSheetStore((state) => state.setCustomFields); /** whether the current session has been authenticated */ const verifyAuth = async (): Promise<{ authenticated: AuthenticationStatus } | void> => { @@ -43,6 +43,7 @@ export default function useGoogleSheet() { } }; + /** requests the revoking of an existing authenticated session */ const revoke = async (): Promise<{ authenticated: AuthenticationStatus } | void> => { try { return revokeAuthentication(); @@ -52,18 +53,18 @@ export default function useGoogleSheet() { }; /** fetches data from a worksheet by its ID */ - const importRundownPreview = async (sheetId: string, fileOptions: ExcelImportMap) => { + const importRundownPreview = async (sheetId: string, fileOptions: ImportMap) => { try { const data = await previewRundown(sheetId, fileOptions); setRundown(data.rundown); - setUserFields(data.userFields); + setCustomFields(data.customFields); } catch (error) { patchStepData({ pullPush: { available: true, error: maybeAxiosError(error) } }); } }; /** writes data to a worksheet by its ID */ - const exportRundown = async (sheetId: string, fileOptions: ExcelImportMap) => { + const exportRundown = async (sheetId: string, fileOptions: ImportMap) => { try { // write data to google await uploadRundown(sheetId, fileOptions); @@ -73,14 +74,14 @@ export default function useGoogleSheet() { } }; - /** applies rundown and userfields to current project */ - const importRundown = async (rundown: OntimeRundown, userFields: UserFields) => { + /** applies rundown and customFields to current project */ + const importRundown = async (rundown: OntimeRundown, customFields: CustomFields) => { try { - await patchData({ rundown, userFields }); + await patchData({ rundown, customFields }); // we are unable to optimistically set the rundown since we need // it to be normalised await queryClient.invalidateQueries({ - queryKey: [...RUNDOWN, ...USERFIELDS], + queryKey: [RUNDOWN, CUSTOM_FIELDS], }); } catch (error) { patchStepData({ pullPush: { available: true, error: maybeAxiosError(error) } }); diff --git a/apps/client/src/features/app-settings/panel/sources-panel/useSheetStore.ts b/apps/client/src/features/app-settings/panel/sources-panel/useSheetStore.ts index aca250fed..7f8b0e1f4 100644 --- a/apps/client/src/features/app-settings/panel/sources-panel/useSheetStore.ts +++ b/apps/client/src/features/app-settings/panel/sources-panel/useSheetStore.ts @@ -1,30 +1,30 @@ -import { AuthenticationStatus, OntimeRundown, UserFields } from 'ontime-types'; -import { defaultExcelImportMap, ExcelImportMap } from 'ontime-utils'; +import { AuthenticationStatus, CustomFields, OntimeRundown } from 'ontime-types'; +import { defaultImportMap, ImportMap } from 'ontime-utils'; import { create } from 'zustand'; -// TODO: persist excelFileOptions to localStorage - type SheetStore = { stepData: typeof initialStepData; patchStepData: (patch: Partial) => void; + spreadsheet: File | null; + setSpreadsheet: (spreadsheet: File | null) => void; + sheetId: string | null; setSheetId: (sheetId: string | null) => void; authenticationStatus: AuthenticationStatus; setAuthenticationStatus: (status: AuthenticationStatus) => void; + // we get this from a preview response rundown: OntimeRundown | null; setRundown: (rundown: OntimeRundown | null) => void; - userFields: UserFields | null; - setUserFields: (userFields: UserFields | null) => void; + // we get this from a preview response + customFields: CustomFields | null; + setCustomFields: (customFields: CustomFields | null) => void; - worksheetOptions: string[] | null; - setWorksheetOptions: (worksheetOptions: string[] | null) => void; - - excelFileOptions: ExcelImportMap; - patchExcelFileOptions: (field: T, value: ExcelImportMap[T]) => void; + spreadsheetImportMap: ImportMap; + patchSpreadsheetImportMap: (field: T, value: ImportMap[T]) => void; reset: () => void; resetPreview: () => void; @@ -39,12 +39,12 @@ const initialStepData = { const initialState = { stepData: initialStepData, + spreadsheet: null, sheetId: null, authenticationStatus: 'not_authenticated' as AuthenticationStatus, rundown: null, - userFields: null, - worksheetOptions: null, - excelFileOptions: defaultExcelImportMap, + customFields: null, + spreadsheetImportMap: defaultImportMap, }; export const useSheetStore = create((set, get) => ({ @@ -55,23 +55,23 @@ export const useSheetStore = create((set, get) => ({ set({ stepData: { ...stepData, ...patch } }); }, + setSpreadsheet: (spreadsheet: File | null) => set({ spreadsheet }), + setSheetId: (sheetId: string | null) => set({ sheetId }), setAuthenticationStatus: (status: AuthenticationStatus) => set({ authenticationStatus: status }), setRundown: (rundown: OntimeRundown | null) => set({ rundown }), - setUserFields: (userFields: UserFields | null) => set({ userFields }), + setCustomFields: (customFields: CustomFields | null) => set({ customFields }), - setWorksheetOptions: (worksheetOptions: string[] | null) => set({ worksheetOptions }), - - patchExcelFileOptions: (field: T, value: ExcelImportMap[T]) => { - const excelFileOptions = get().excelFileOptions; - if (excelFileOptions[field] !== value) { - excelFileOptions[field] = value; + patchSpreadsheetImportMap: (field: T, value: ImportMap[T]) => { + const currentImportMap = get().spreadsheetImportMap; + if (currentImportMap[field] !== value) { + currentImportMap[field] = value; } }, reset: () => set(initialState), - resetPreview: () => set({ rundown: null, userFields: null }), + resetPreview: () => set({ rundown: null, customFields: null }), })); diff --git a/apps/client/src/features/cuesheet/defaults.ts b/apps/client/src/features/cuesheet/defaults.ts index fd6aac5b9..dd755e213 100644 --- a/apps/client/src/features/cuesheet/defaults.ts +++ b/apps/client/src/features/cuesheet/defaults.ts @@ -13,30 +13,9 @@ export const defaultColumnOrder: OntimeEntryCommonKeys[] = [ 'subtitle', 'presenter', 'note', - 'user0', - 'user1', - 'user2', - 'user3', - 'user4', - 'user5', - 'user6', - 'user7', - 'user8', - 'user9', ]; /** * @description set default hidden columns */ -export const defaultHiddenColumns: (keyof OntimeEvent)[] = [ - 'user0', - 'user1', - 'user2', - 'user3', - 'user4', - 'user5', - 'user6', - 'user7', - 'user8', - 'user9', -]; +export const defaultHiddenColumns: (keyof OntimeEvent)[] = []; diff --git a/apps/client/src/features/modals/upload-modal/UploadFile.tsx b/apps/client/src/features/modals/upload-modal/UploadFile.tsx index 3497d3fbe..396ad2015 100644 --- a/apps/client/src/features/modals/upload-modal/UploadFile.tsx +++ b/apps/client/src/features/modals/upload-modal/UploadFile.tsx @@ -1,9 +1,10 @@ import { ChangeEvent, useRef, useState } from 'react'; import { Input } from '@chakra-ui/react'; +import { validateFile } from '../../../common/utils/uploadUtils'; + import UploadEntry from './upload-entry/UploadEntry'; import { useUploadModalContextStore } from './uploadModalContext'; -import { validateFile } from './uploadUtils'; import style from './UploadModal.module.scss'; @@ -52,12 +53,12 @@ export default function UploadFile() { style={{ display: 'none' }} type='file' onChange={handleFile} - accept='.json, .xlsx' + accept='.json' data-testid='file-input' /> {!file && (
- Click to select Ontime project or xlsx rundown + Click to select Ontime project
)} {(file || errors) && } diff --git a/apps/client/src/features/modals/upload-modal/UploadModal.tsx b/apps/client/src/features/modals/upload-modal/UploadModal.tsx index c5872303e..2d53a6514 100644 --- a/apps/client/src/features/modals/upload-modal/UploadModal.tsx +++ b/apps/client/src/features/modals/upload-modal/UploadModal.tsx @@ -9,27 +9,15 @@ import { ModalHeader, ModalOverlay, } from '@chakra-ui/react'; -import { useQueryClient } from '@tanstack/react-query'; -import { OntimeRundown, UserFields } from 'ontime-types'; -import { defaultExcelImportMap, ExcelImportMap } from 'ontime-utils'; +import { OntimeRundown } from 'ontime-types'; -import { RUNDOWN, USERFIELDS } from '../../../common/api/apiConstants'; import { invalidateAllCaches, maybeAxiosError } from '../../../common/api/apiUtils'; -import { - patchData, - postPreviewExcel, - ProjectFileImportOptions, - uploadProjectFile, -} from '../../../common/api/ontimeApi'; -import { userFieldsPlaceholder } from '../../../common/models/UserFields'; +import { ProjectFileImportOptions, uploadProjectFile } from '../../../common/api/ontimeApi'; +import { isOntimeFile } from '../../../common/utils/uploadUtils'; -import PreviewExcel from './preview/PreviewExcel'; -import ExcelFileOptions from './upload-options/ExcelFileOptions'; import OntimeFileOptions from './upload-options/OntimeFileOptions'; -import UploadStepTracker from './upload-step/UploadStep'; import UploadFile from './UploadFile'; import { useUploadModalContextStore } from './uploadModalContext'; -import { getPersistedOptions, isExcelFile, isOntimeFile, persistOptions } from './uploadUtils'; import style from './UploadModal.module.scss'; @@ -41,19 +29,15 @@ interface UploadModalProps { } export default function UploadModal({ onClose, isOpen }: UploadModalProps) { - const queryClient = useQueryClient(); - const { file, setProgress, clear } = useUploadModalContextStore(); const [uploadStep, setUploadStep] = useState('import'); const [submitting, setSubmitting] = useState(false); const [rundown, setRundown] = useState(null); - const [userFields, setUserFields] = useState(null); const [errors, setErrors] = useState(''); const ontimeFileOptions = useRef>({}); - const excelFileOptions = useRef(defaultExcelImportMap); const updateOntimeFileOptions = ( field: T, @@ -62,27 +46,12 @@ export default function UploadModal({ onClose, isOpen }: UploadModalProps) { ontimeFileOptions.current = { ...ontimeFileOptions.current, [field]: value }; }; - const updateExcelFileOptions = (field: T, value: ExcelImportMap[T]) => { - if (excelFileOptions.current[field] !== value) { - excelFileOptions.current = { ...excelFileOptions.current, [field]: value }; - } - }; - - // We want to populate the options with any previous options given by the user - useEffect(() => { - const excelOptions = getPersistedOptions('excel'); - if (excelOptions) { - excelFileOptions.current = excelOptions; - } - }, []); - // if the modal re-opens, we want to restart all states useEffect(() => { clear(); setUploadStep('import'); setSubmitting(false); setRundown(null); - setUserFields(null); setErrors(''); }, [clear, isOpen]); @@ -102,10 +71,6 @@ export default function UploadModal({ onClose, isOpen }: UploadModalProps) { await handleOntimeFile(file, options); await invalidateAllCaches(); doClose = true; - } else if (isExcelFile(file)) { - const options = excelFileOptions.current; - persistOptions({ optionType: 'excel', options }); - await handleExcelFile(file, options); } } catch (error) { const message = maybeAxiosError(error); @@ -118,17 +83,6 @@ export default function UploadModal({ onClose, isOpen }: UploadModalProps) { } } - // when we upload excel, we populate state with preview data - async function handleExcelFile(file: File, options: ExcelImportMap) { - const response = await postPreviewExcel(file, setProgress, options); - if (response.status === 200) { - setRundown(response.data.rundown); - setUserFields(response.data.userFields); - // in excel imports we have an extra review step - setUploadStep('review'); - } - } - // when we upload project files, no extra operations are done async function handleOntimeFile(file: File, options: Partial) { await uploadProjectFile(file, setProgress, options); @@ -139,37 +93,9 @@ export default function UploadModal({ onClose, isOpen }: UploadModalProps) { const handleClose = () => { clear(); setRundown([]); - setUserFields(userFieldsPlaceholder); onClose(); }; - const handleFinalise = async () => { - // this step is currently only used for excel files, after preview - if (isExcel && rundown && userFields) { - let doClose = false; - setSubmitting(true); - try { - await patchData({ rundown, userFields }); - // TODO: broken :( - // we need to normalise the data here - queryClient.setQueryData(RUNDOWN, { rundown, revision: -1 }); - queryClient.setQueryData(USERFIELDS, userFields); - await queryClient.invalidateQueries({ - queryKey: [...RUNDOWN, ...USERFIELDS], - }); - doClose = true; - } catch (error) { - const message = maybeAxiosError(error); - setErrors(`Failed applying changes ${message}`); - } finally { - setSubmitting(false); - if (doClose) { - handleClose(); - } - } - } - }; - const undoReview = () => { setUploadStep('import'); setErrors(''); @@ -177,11 +103,10 @@ export default function UploadModal({ onClose, isOpen }: UploadModalProps) { const isImporting = uploadStep === 'import'; const isReview = uploadStep === 'review'; - const isExcel = isExcelFile(file); const isOntime = isOntimeFile(file); const handleGoBack = isImporting ? undefined : undoReview; - const handleSubmit = isImporting ? handleUpload : handleFinalise; + const handleSubmit = handleUpload; const disableSubmit = (isImporting && !file) || (isReview && rundown === null); const disableGoBack = isImporting; const submitText = isImporting ? 'Import' : 'Finish'; @@ -202,18 +127,10 @@ export default function UploadModal({ onClose, isOpen }: UploadModalProps) { File import - {isExcel && } - {uploadStep === 'import' ? ( - <> - - {isOntime && } - {isExcel && ( - - )} - - ) : ( - - )} + <> + + {isOntime && } +
{errors &&
{errors}
}
diff --git a/apps/client/src/features/modals/upload-modal/preview/PreviewExcel.tsx b/apps/client/src/features/modals/upload-modal/preview/PreviewExcel.tsx deleted file mode 100644 index 25b32031f..000000000 --- a/apps/client/src/features/modals/upload-modal/preview/PreviewExcel.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { OntimeRundown, UserFields } from 'ontime-types'; - -import PreviewRundown from './PreviewRundown'; - -import style from '../../Modal.module.scss'; - -interface PreviewExcelProps { - rundown: OntimeRundown; - userFields: UserFields; -} - -export default function PreviewExcel(props: PreviewExcelProps) { - const { rundown, userFields } = props; - - return ( -
-
Review Rundown
- -
- ); -} diff --git a/apps/client/src/features/modals/upload-modal/preview/PreviewRundown.tsx b/apps/client/src/features/modals/upload-modal/preview/PreviewRundown.tsx deleted file mode 100644 index 64d0912cd..000000000 --- a/apps/client/src/features/modals/upload-modal/preview/PreviewRundown.tsx +++ /dev/null @@ -1,151 +0,0 @@ -import { Fragment } from 'react'; -import { isOntimeBlock, isOntimeEvent, OntimeRundown, UserFields } from 'ontime-types'; -import { millisToString } from 'ontime-utils'; - -import { getAccessibleColour } from '../../../../common/utils/styleUtils'; - -import Tag from './Tag'; - -import style from './PreviewTable.module.scss'; - -interface PreviewRundownProps { - rundown: OntimeRundown; - userFields: UserFields; -} - -function booleanToText(value?: boolean) { - return value ? 'Yes' : undefined; -} - -export default function PreviewRundown({ rundown, userFields }: PreviewRundownProps) { - // we only count Ontime Events which are 1 based in client - let eventIndex = 0; - return ( -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {rundown.map((event) => { - if (isOntimeBlock(event)) { - return ( - - - - - - ); - } - if (!isOntimeEvent(event)) { - return null; - } - eventIndex += 1; - const colour = event.colour ? getAccessibleColour(event.colour) : {}; - const isPublic = booleanToText(event.isPublic); - const skip = booleanToText(event.skip); - return ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {event.note && ( - - - - )} - - ); - })} - -
#TypeCueTitleSubtitlePresenterTime StartTime EndDurationWarning TimeDanger TimeIs PublicSkipColourTimer TypeEnd Action - user0 {userFields.user0} - - user1 {userFields.user1} - - user2 {userFields.user2} - - user3 {userFields.user3} - - user4 {userFields.user4} - - user5 {userFields.user5} - - user6 {userFields.user6} - - user7 {userFields.user7} - - user8 {userFields.user8} - - user9 {userFields.user9} -
- - - - {event.type} - - {event.title}
- {eventIndex} - - {event.type} - {event.cue}{event.title}{event.subtitle}{event.presenter}{millisToString(event.timeStart)}{millisToString(event.timeEnd)}{millisToString(event.duration)}{millisToString(event.timeWarning)}{millisToString(event.timeDanger)}{isPublic && {isPublic}}{skip && {skip}}{event.colour} - {event.timerType} - - {event.endAction} - {event.user0}{event.user1}{event.user2}{event.user3}{event.user4}{event.user5}{event.user6}{event.user7}{event.user8}{event.user9}
- Note: {event.note} -
-
- ); -} diff --git a/apps/client/src/features/modals/upload-modal/preview/PreviewTable.module.scss b/apps/client/src/features/modals/upload-modal/preview/PreviewTable.module.scss deleted file mode 100644 index cb4bb719e..000000000 --- a/apps/client/src/features/modals/upload-modal/preview/PreviewTable.module.scss +++ /dev/null @@ -1,68 +0,0 @@ -.container { - max-width: 100%; - max-height: max(300px, 30vh); - overflow: scroll; -} - -.rundownPreview { - font-size: calc(1rem - 2px); - overflow-x: scroll; - tr td:first-child, - tr th:first-child { - position: sticky; - left: 0; - z-index: 2; - background-color: white; - box-shadow: 1px 0 $gray-50; - } -} - -.header, -.body { - th { - font-weight: 400; - height: unset; - line-height: calc(1rem - 2px); - white-space: nowrap; - padding-left: 0.25rem; - padding-right: 1rem; - } -} - -.header { - position: sticky; - top: 0; - background-color: white; - box-shadow: 0 2px $gray-50; - z-index: 3; - - th { - font-weight: 200; - text-align: left; - } - - tr { - word-wrap: unset; - } -} - -.body { - td { - text-align: left; - vertical-align: top; - padding: 0 0.5em; - } - - .center { - text-align: center; - } - - .nowrap { - white-space: nowrap; - } - - .secondaryRow { - padding: 0.25em 0.25em; - background-color: $gray-50; - } -} diff --git a/apps/client/src/features/modals/upload-modal/upload-entry/UploadEntry.tsx b/apps/client/src/features/modals/upload-modal/upload-entry/UploadEntry.tsx index 2c3545f75..e59649316 100644 --- a/apps/client/src/features/modals/upload-modal/upload-entry/UploadEntry.tsx +++ b/apps/client/src/features/modals/upload-modal/upload-entry/UploadEntry.tsx @@ -3,7 +3,7 @@ import { IoClose } from '@react-icons/all-files/io5/IoClose'; import { IoDocumentTextOutline } from '@react-icons/all-files/io5/IoDocumentTextOutline'; import { IoWarningOutline } from '@react-icons/all-files/io5/IoWarningOutline'; -import { isExcelFile, isOntimeFile } from '../uploadUtils'; +import { isOntimeFile } from '../../../../common/utils/uploadUtils'; import style from './UploadEntry.module.scss'; @@ -33,8 +33,6 @@ export default function UploadEntry(props: UploadEntryProps) { let fileType = ''; if (isOntimeFile(file)) { fileType = 'Ontime Project File'; - } else if (isExcelFile(file)) { - fileType = 'Excel Rundown'; } return ( diff --git a/apps/client/src/features/modals/upload-modal/upload-options/ExcelFileOptions.tsx b/apps/client/src/features/modals/upload-modal/upload-options/ExcelFileOptions.tsx deleted file mode 100644 index 5333ab463..000000000 --- a/apps/client/src/features/modals/upload-modal/upload-options/ExcelFileOptions.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { ExcelImportMap } from 'ontime-utils'; - -import ImportMapTable, { type TableEntry } from './ImportMapTable'; - -import style from '../UploadModal.module.scss'; - -interface ExcelFileOptionsProps { - importOptions: ExcelImportMap; - updateOptions: (field: T, value: ExcelImportMap[T]) => void; -} - -export default function ExcelFileOptions(props: ExcelFileOptionsProps) { - const { importOptions, updateOptions } = props; - - const worksheet: TableEntry[] = [{ label: 'Worksheet', title: 'worksheet', value: importOptions.worksheet }]; - - const timings: TableEntry[] = [ - { label: 'Start time', title: 'timeStart', value: importOptions.timeStart }, - { label: 'End Time', title: 'timeEnd', value: importOptions.timeEnd }, - { label: 'Duration', title: 'duration', value: importOptions.duration }, - { label: 'Warning Time', title: 'timeWarning', value: importOptions.timeWarning }, - { label: 'Danger Time', title: 'timeDanger', value: importOptions.timeDanger }, - ]; - - const titles: TableEntry[] = [ - { label: 'Cue', title: 'cue', value: importOptions.cue }, - { label: 'Colour', title: 'colour', value: importOptions.colour }, - { label: 'Title', title: 'title', value: importOptions.title }, - { label: 'Presenter', title: 'presenter', value: importOptions.presenter }, - { label: 'Subtitle', title: 'subtitle', value: importOptions.subtitle }, - { label: 'Note', title: 'note', value: importOptions.note }, - ]; - - const options: TableEntry[] = [ - { label: 'Is Public', title: 'isPublic', value: importOptions.isPublic }, - { label: 'Skip', title: 'skip', value: importOptions.skip }, - { label: 'Timer Type', title: 'timerType', value: importOptions.timerType }, - { label: 'End Action', title: 'endAction', value: importOptions.endAction }, - ]; - - const userFields: TableEntry[] = [ - { label: 'User 0', title: 'user0', value: importOptions.user0 }, - { label: 'User 1', title: 'user1', value: importOptions.user1 }, - { label: 'User 2', title: 'user2', value: importOptions.user2 }, - { label: 'User 3', title: 'user3', value: importOptions.user3 }, - { label: 'User 4', title: 'user4', value: importOptions.user4 }, - { label: 'User 5', title: 'user5', value: importOptions.user5 }, - { label: 'User 6', title: 'user6', value: importOptions.user6 }, - { label: 'User 7', title: 'user7', value: importOptions.user7 }, - { label: 'User 8', title: 'user8', value: importOptions.user8 }, - { label: 'User 9', title: 'user9', value: importOptions.user9 }, - ]; - - return ( -
-
- -
- -
- - -
- -
- - -
-
- ); -} diff --git a/apps/client/src/features/modals/upload-modal/upload-options/ImportMapTable.module.scss b/apps/client/src/features/modals/upload-modal/upload-options/ImportMapTable.module.scss deleted file mode 100644 index 7a980a9bb..000000000 --- a/apps/client/src/features/modals/upload-modal/upload-options/ImportMapTable.module.scss +++ /dev/null @@ -1,30 +0,0 @@ -.importTable { - margin: 0.5rem; - height: fit-content; - - thead { - color: $gray-500; - text-transform: uppercase; - width: 10em; - } - - tr:hover { - background-color: $gray-50; - } - - tbody { - td { - max-width: fit-content; - } - } -} - -.label { - display: inline-block; - min-width: 6em; - font-size: $inner-section-text-size; -} - -.input { - width: 100%; -} diff --git a/apps/client/src/features/modals/upload-modal/upload-options/ImportMapTable.tsx b/apps/client/src/features/modals/upload-modal/upload-options/ImportMapTable.tsx deleted file mode 100644 index bcbfa577f..000000000 --- a/apps/client/src/features/modals/upload-modal/upload-options/ImportMapTable.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { Input } from '@chakra-ui/react'; -import { ExcelImportMap } from 'ontime-utils'; - -import style from './ImportMapTable.module.scss'; - -export type TableEntry = { label: string; title: keyof ExcelImportMap; value: string }; - -interface ImportMapTableProps { - title: string; - fields: TableEntry[]; - handleOnChange: (field: keyof ExcelImportMap, value: string) => void; -} - -export default function ImportMapTable(props: ImportMapTableProps) { - const { title, fields, handleOnChange } = props; - - return ( - - - - - - - - {fields.map((field) => { - return ( - - - - - ); - })} - -
{title}
- - - { - handleOnChange(field.title, event.target.value); - }} - /> -
- ); -} diff --git a/apps/client/src/features/operator/Operator.tsx b/apps/client/src/features/operator/Operator.tsx index c6ea04770..e4378087c 100644 --- a/apps/client/src/features/operator/Operator.tsx +++ b/apps/client/src/features/operator/Operator.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { useSearchParams } from 'react-router-dom'; -import { CustomFields, isOntimeEvent, OntimeEvent, SupportedEvent, UserFields } from 'ontime-types'; +import { CustomField, CustomFields, isOntimeEvent, OntimeEvent, SupportedEvent } from 'ontime-types'; import { getFirstEventNormal, getLastEventNormal } from 'ontime-utils'; import NavigationMenu from '../../common/components/navigation-menu/NavigationMenu'; @@ -107,7 +107,7 @@ export default function Operator() { const handleEdit = useCallback( (event: EditEvent) => { - const field = searchParams.get('subscribe') as keyof UserFields | null; + const field = searchParams.get('subscribe') as keyof CustomField | null; if (field) { setEditEvent({ ...event, field }); diff --git a/apps/client/src/features/rundown/event-editor/EventEditor.module.scss b/apps/client/src/features/rundown/event-editor/EventEditor.module.scss index d4f323d36..f3d6a7d46 100644 --- a/apps/client/src/features/rundown/event-editor/EventEditor.module.scss +++ b/apps/client/src/features/rundown/event-editor/EventEditor.module.scss @@ -7,10 +7,12 @@ display: flex; flex-direction: column; overflow-x: auto; + } .content { padding-right: 4px; + padding-bottom: 4rem; flex: 1; display: flex; @@ -44,6 +46,7 @@ font-size: calc(1rem - 3px); color: $label-gray; margin-bottom: 0.25rem; + text-transform: capitalize; } .delayLabel { diff --git a/apps/client/src/features/rundown/event-editor/EventEditor.tsx b/apps/client/src/features/rundown/event-editor/EventEditor.tsx index 95a335eba..7a2681109 100644 --- a/apps/client/src/features/rundown/event-editor/EventEditor.tsx +++ b/apps/client/src/features/rundown/event-editor/EventEditor.tsx @@ -16,25 +16,7 @@ import style from './EventEditor.module.scss'; export type EventEditorSubmitActions = keyof OntimeEvent; -// TODO: this logic will become dynamic -export type EditorUpdateFields = - | 'cue' - | 'title' - | 'presenter' - | 'subtitle' - | 'note' - | 'colour' - | 'user0' - | 'user1' - | 'user2' - | 'user3' - | 'user4' - | 'user5' - | 'user6' - | 'user7' - | 'user8' - | 'user9' - | CustomFieldLabel; // TODO: keyof customFields +export type EditorUpdateFields = 'cue' | 'title' | 'presenter' | 'subtitle' | 'note' | 'colour' | CustomFieldLabel; // TODO: keyof customFields export default function EventEditor() { const selectedEvents = useEventSelection((state) => state.selectedEvents); @@ -85,8 +67,6 @@ export default function EventEditor() { ); } - const customKeys = Object.keys(customFields ?? {}); - return (
@@ -123,13 +103,17 @@ export default function EventEditor() { Manage
- {customKeys.map((label) => { + {Object.keys(customFields).map((label) => { + const key = `${event.id}-${label}`; + const fieldName = `custom-${label}`; + const initialValue = event.custom[label]?.value ?? ''; + return ( {
- - -
+ + + ); diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 22190e69c..d5c508116 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -153,11 +153,12 @@ export const startServer = async () => { checkStart(OntimeStartOrder.InitServer); const { serverPort } = DataProvider.getSettings(); + const returnMessage = `Ontime is listening on port ${serverPort}`; expressServer = http.createServer(app); - socket.init(expressServer); + logger.info(LogOrigin.Server, returnMessage); /** * Module initialises the services and provides initial payload for the store diff --git a/apps/server/src/classes/data-provider/DataProvider.ts b/apps/server/src/classes/data-provider/DataProvider.ts index bd59d7756..f13c817d8 100644 --- a/apps/server/src/classes/data-provider/DataProvider.ts +++ b/apps/server/src/classes/data-provider/DataProvider.ts @@ -8,7 +8,6 @@ import { ViewSettings, DatabaseModel, OSCSettings, - UserFields, Alias, Settings, CustomFields, @@ -75,10 +74,6 @@ export class DataProvider { await this.persist(); } - static getUserFields() { - return { ...data.userFields }; - } - static getViewSettings() { return { ...data.viewSettings }; } @@ -88,11 +83,6 @@ export class DataProvider { await this.persist(); } - static async setUserFields(newData: UserFields) { - data.userFields = { ...newData }; - await this.persist(); - } - static async setOsc(newData: OSCSettings): Promise { data.osc = { ...newData }; await this.persist(); @@ -124,7 +114,6 @@ export class DataProvider { data.osc = mergedData.osc; data.http = mergedData.http; data.aliases = mergedData.aliases; - data.userFields = mergedData.userFields; data.customFields = mergedData.customFields; data.rundown = mergedData.rundown; await this.persist(); diff --git a/apps/server/src/classes/data-provider/DataProvider.utils.ts b/apps/server/src/classes/data-provider/DataProvider.utils.ts index 9ddfeeaab..7550ed030 100644 --- a/apps/server/src/classes/data-provider/DataProvider.utils.ts +++ b/apps/server/src/classes/data-provider/DataProvider.utils.ts @@ -6,7 +6,7 @@ import { DatabaseModel } from 'ontime-types'; * @param {object} newData */ export function safeMerge(existing: DatabaseModel, newData: Partial) { - const { rundown, project, settings, viewSettings, aliases, customFields, userFields, osc, http } = newData || {}; + const { rundown, project, settings, viewSettings, aliases, customFields, osc, http } = newData || {}; return { ...existing, @@ -16,10 +16,6 @@ export function safeMerge(existing: DatabaseModel, newData: Partial value !== null))), - }, osc: { ...existing.osc, ...osc }, http: { ...existing.http, ...http }, }; diff --git a/apps/server/src/classes/data-provider/__tests__/DataProvider.test.ts b/apps/server/src/classes/data-provider/__tests__/DataProvider.test.ts index 9645ad8f8..eb1caf88f 100644 --- a/apps/server/src/classes/data-provider/__tests__/DataProvider.test.ts +++ b/apps/server/src/classes/data-provider/__tests__/DataProvider.test.ts @@ -29,18 +29,6 @@ describe('safeMerge', () => { dangerColor: '#ED3333', }, aliases: [], - userFields: { - user0: 'existing user0', - user1: 'existing user1', - user2: 'existing user2', - user3: 'existing user3', - user4: 'existing user4', - user5: 'existing user5', - user6: 'existing user6', - user7: 'existing user7', - user8: 'existing user8', - user9: 'existing user9', - }, customFields: { lighting: { type: 'string', label: 'lighting', colour: 'red' }, vfx: { type: 'string', label: 'vfx', colour: 'blue' }, @@ -166,18 +154,6 @@ describe('safeMerge', () => { endMessage: '', }, aliases: [], - userFields: { - user0: 'user0', - user1: 'user1', - user2: 'user2', - user3: 'user3', - user4: 'user4', - user5: 'user5', - user6: 'user6', - user7: 'user7', - user8: 'user8', - user9: 'user9', - }, osc: { portIn: 8888, portOut: 9999, @@ -200,34 +176,6 @@ describe('safeMerge', () => { expect(mergedData.aliases).toEqual(newData.aliases); }); - it('merges userFields into existing object', () => { - const existing = { - userFields: { - user0: 'Alice', - user1: 'Bob', - }, - }; - - const newData = { - userFields: { - user2: 'Charlie', - user3: 'David', - user4: null, - }, - }; - - const expected = { - user0: 'Alice', - user1: 'Bob', - user2: 'Charlie', - user3: 'David', - }; - - //@ts-expect-error -- testing partial merge - const result = safeMerge(existing, newData); - expect(result.userFields).toEqual(expected); - }); - it('merges customFields into existing object', () => { const existing = { customFields: { diff --git a/apps/server/src/controllers/integrationController.config.ts b/apps/server/src/controllers/integrationController.config.ts index 4b6540b18..f03226fb0 100644 --- a/apps/server/src/controllers/integrationController.config.ts +++ b/apps/server/src/controllers/integrationController.config.ts @@ -3,6 +3,7 @@ import { editEvent } from '../services/rundown-service/RundownService.js'; import { getEventWithId } from '../services/rundown-service/rundownUtils.js'; import { coerceString, coerceNumber, coerceBoolean, coerceColour } from '../utils/coerceType.js'; +// TODO: handle custom fields const whitelistedPayload = { title: coerceString, subtitle: coerceString, @@ -16,17 +17,6 @@ const whitelistedPayload = { skip: coerceBoolean, colour: coerceColour, - - user0: coerceString, - user1: coerceString, - user2: coerceString, - user3: coerceString, - user4: coerceString, - user5: coerceString, - user6: coerceString, - user7: coerceString, - user8: coerceString, - user9: coerceString, }; export function parse(property: string, value: unknown) { diff --git a/apps/server/src/controllers/ontimeController.ts b/apps/server/src/controllers/ontimeController.ts index 75e2974d3..0fc1c5dc6 100644 --- a/apps/server/src/controllers/ontimeController.ts +++ b/apps/server/src/controllers/ontimeController.ts @@ -8,7 +8,7 @@ import type { ProjectFileListResponse, OSCSettings, } from 'ontime-types'; -import { ExcelImportOptions, deepmerge } from 'ontime-utils'; +import { ImportOptions } from 'ontime-utils'; import { RequestHandler, Request, Response } from 'express'; import fs from 'fs'; @@ -72,16 +72,16 @@ export const dbDownload = async (_req: Request, res: Response) => { /** * Parses a file and returns the result objects - * @param file + * @param filePath * @param _req * @param _res * @param options */ -async function parseFile(file, _req: Request, _res: Response, options: ExcelImportOptions) { - if (!fs.existsSync(file)) { +async function parseFile(filePath: string, _req: Request, _res: Response, options: ImportOptions) { + if (!fs.existsSync(filePath)) { throw new Error('Upload failed'); } - const result = await fileHandler(file, options); + const result = await fileHandler(filePath, options); return result.data; } @@ -186,29 +186,6 @@ export const postAliases = async (req: Request, res: Response) => { } }; -// Create controller for GET request to '/ontime/userfields' -// Returns - -export const getUserFields = async (_req: Request, res: Response) => { - const userFields = DataProvider.getUserFields(); - res.status(200).send(userFields); -}; - -// Create controller for POST request to '/ontime/userfields' -// Returns ACK message -export const postUserFields = async (req: Request, res: Response) => { - if (failEmptyObjects(req.body, res)) { - return; - } - try { - const persistedData = DataProvider.getUserFields(); - const newData = deepmerge(persistedData, req.body); - await DataProvider.setUserFields(newData); - res.status(200).send(newData); - } catch (error) { - res.status(400).send({ message: String(error) }); - } -}; - // Create controller for POST request to '/ontime/settings' // Returns - export const getSettings = async (_req: Request, res: Response) => { @@ -356,7 +333,9 @@ export const postHTTP = async (req: Request, res: Response { }; /** - * uploads and parses an excel file + * uploads and parses an excel spreadsheet * @returns parsed result */ -export async function previewExcel(req: Request, res: Response) { +export async function previewSpreadsheet(req: Request, res: Response) { if (!req.file) { res.status(400).send({ message: 'File not found' }); return; @@ -413,8 +392,8 @@ export async function previewExcel(req: Request, res: Response) { try { const options = JSON.parse(req.body.options); - const file = req.file.path; - const data = await parseFile(file, req, res, options); + const filePath = req.file.path; + const data = await parseFile(filePath, req, res, options); res.status(200).send(data); } catch (error) { res.status(500).send({ message: String(error) }); diff --git a/apps/server/src/controllers/ontimeController.validate.ts b/apps/server/src/controllers/ontimeController.validate.ts index c617722fa..5d2a35215 100644 --- a/apps/server/src/controllers/ontimeController.validate.ts +++ b/apps/server/src/controllers/ontimeController.validate.ts @@ -39,28 +39,6 @@ export const validateAliases = [ }, ]; -/** - * @description Validates object for POST /ontime/userfields - */ -export const validateUserFields = [ - body('user0').exists().isString().trim(), - body('user1').exists().isString().trim(), - body('user2').exists().isString().trim(), - body('user3').exists().isString().trim(), - body('user4').exists().isString().trim(), - body('user5').exists().isString().trim(), - body('user6').exists().isString().trim(), - body('user7').exists().isString().trim(), - body('user8').exists().isString().trim(), - body('user9').exists().isString().trim(), - - (req: Request, res: Response, next: NextFunction) => { - const errors = validationResult(req); - if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() }); - next(); - }, -]; - /** * @description Validates object for POST /ontime/settings */ @@ -122,7 +100,7 @@ export const validatePatchProjectFile = [ body('settings').isObject().optional({ nullable: false }), body('viewSettings').isObject().optional({ nullable: false }), body('aliases').isArray().optional({ nullable: false }), - body('userFields').isObject().optional({ nullable: false }), + body('customFields').isObject().optional({ nullable: false }), body('osc').isObject().optional({ nullable: false }), (req: Request, res: Response, next: NextFunction) => { diff --git a/apps/server/src/controllers/sheetController.validate.ts b/apps/server/src/controllers/sheetController.validate.ts index 734407812..05dda3c34 100644 --- a/apps/server/src/controllers/sheetController.validate.ts +++ b/apps/server/src/controllers/sheetController.validate.ts @@ -1,4 +1,4 @@ -import { defaultExcelImportMap } from 'ontime-utils'; +import { isImportMap } from 'ontime-utils'; import { body, param, validationResult } from 'express-validator'; import { NextFunction, Request, Response } from 'express'; @@ -26,17 +26,8 @@ export const validateSheetOptions = [ .exists() .isObject() .custom((content) => { - // Check if the fileContent has the same keys as defaultExcelImportMap - const hasValidKeys = Object.keys(defaultExcelImportMap).every((key) => key in content); - - // Check if all values in fileContent are strings - const hasValidValues = Object.values(content).every((value) => typeof value === 'string'); - - if (!hasValidKeys || !hasValidValues) { - throw new Error('Invalid file format'); - } - - return true; + const isValid = isImportMap(content); + return isValid; }), (req: Request, res: Response, next: NextFunction) => { diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index de8a9b3d7..5a6de5919 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -2,18 +2,16 @@ import { initAssets, startIntegrations, startOSCServer, startServer } from './ap async function startOntime() { try { - console.log('Starting Ontime'); - console.log('Loading Assets'); + console.log('Request: Initialise assets...'); await initAssets(); - console.log('Starting Server'); + console.log('Request: Start server...'); await startServer(); - console.log('Starting OSC Server'); + console.log('Request: Start OSC server...'); await startOSCServer(); - console.log('Starting Integrations'); + console.log('Request: Start OSC integrations...'); await startIntegrations(); } catch (error) { - console.log('Error starting Ontime'); - console.log(error); + console.log(`Request failed: ${error}`); } } diff --git a/apps/server/src/models/dataModel.ts b/apps/server/src/models/dataModel.ts index 73ead6c0d..893e1aaee 100644 --- a/apps/server/src/models/dataModel.ts +++ b/apps/server/src/models/dataModel.ts @@ -29,18 +29,6 @@ export const dbModel: DatabaseModel = { }, aliases: [], customFields: {}, - userFields: { - user0: 'user0', - user1: 'user1', - user2: 'user2', - user3: 'user3', - user4: 'user4', - user5: 'user5', - user6: 'user6', - user7: 'user7', - user8: 'user8', - user9: 'user9', - }, osc: { portIn: 8888, portOut: 9999, diff --git a/apps/server/src/models/eventsDefinition.ts b/apps/server/src/models/eventsDefinition.ts index 78d3d25cb..654e448e6 100644 --- a/apps/server/src/models/eventsDefinition.ts +++ b/apps/server/src/models/eventsDefinition.ts @@ -23,16 +23,6 @@ export const event: Omit = { isPublic: false, skip: false, colour: '', - user0: '', - user1: '', - user2: '', - user3: '', - user4: '', - user5: '', - user6: '', - user7: '', - user8: '', - user9: '', type: SupportedEvent.Event, revision: 0, timeWarning: 120000, diff --git a/apps/server/src/routes/ontimeRouter.ts b/apps/server/src/routes/ontimeRouter.ts index e6abcc65a..a8f58a277 100644 --- a/apps/server/src/routes/ontimeRouter.ts +++ b/apps/server/src/routes/ontimeRouter.ts @@ -8,16 +8,14 @@ import { getOSC, getHTTP, getSettings, - getUserFields, getViewSettings, patchPartialProjectFile, poll, postAliases, postOSC, postSettings, - postUserFields, postViewSettings, - previewExcel, + previewSpreadsheet, postHTTP, duplicateProjectFile, listProjects, @@ -32,7 +30,6 @@ import { validateOSC, validatePatchProjectFile, validateSettings, - validateUserFields, viewValidator, validateHTTP, validateProjectDuplicate, @@ -61,11 +58,12 @@ router.get('/db', dbDownload); // create route between controller and '/ontime/db' endpoint router.post('/db', uploadFile, dbUpload); -// create route between controller and '/ontime/excel' endpoint +// create route between controller and '/ontime/db' endpoint router.patch('/db', validatePatchProjectFile, patchPartialProjectFile); -// create route between controller and '/ontime/preview-spreadsheet' endpoint -router.post('/preview-spreadsheet', uploadFile, previewExcel); +// create route between controller and '/spreadsheet/preview' endpoint +// TODO: validate import map +router.post('/spreadsheet/preview', uploadFile, previewSpreadsheet); // create route between controller and '/ontime/settings' endpoint router.get('/settings', getSettings); @@ -85,12 +83,6 @@ router.get('/aliases', getAliases); // create route between controller and '/ontime/aliases' endpoint router.post('/aliases', validateAliases, postAliases); -// create route between controller and '/ontime/aliases' endpoint -router.get('/userfields', getUserFields); - -// create route between controller and '/ontime/aliases' endpoint -router.post('/userfields', validateUserFields, postUserFields); - // create route between controller and '/ontime/info' endpoint router.get('/info', getInfo); diff --git a/apps/server/src/services/rundown-service/__tests__/rundownCache.test.ts b/apps/server/src/services/rundown-service/__tests__/rundownCache.test.ts index c1068f0c8..1d86c7fd9 100644 --- a/apps/server/src/services/rundown-service/__tests__/rundownCache.test.ts +++ b/apps/server/src/services/rundown-service/__tests__/rundownCache.test.ts @@ -408,16 +408,6 @@ describe('calculateRuntimeDelays', () => { isPublic: true, skip: false, colour: '', - user0: '', - user1: '', - user2: '', - user3: '', - user4: '', - user5: '', - user6: '', - user7: '', - user8: '', - user9: '', type: SupportedEvent.Event, revision: 0, timeWarning: 120000, @@ -446,16 +436,6 @@ describe('calculateRuntimeDelays', () => { isPublic: true, skip: false, colour: '', - user0: '', - user1: '', - user2: '', - user3: '', - user4: '', - user5: '', - user6: '', - user7: '', - user8: '', - user9: '', type: SupportedEvent.Event, revision: 0, timeWarning: 120000, @@ -484,16 +464,6 @@ describe('calculateRuntimeDelays', () => { isPublic: true, skip: false, colour: '', - user0: '', - user1: '', - user2: '', - user3: '', - user4: '', - user5: '', - user6: '', - user7: '', - user8: '', - user9: '', type: SupportedEvent.Event, revision: 0, timeWarning: 120000, @@ -522,16 +492,6 @@ describe('calculateRuntimeDelays', () => { isPublic: true, skip: false, colour: '', - user0: '', - user1: '', - user2: '', - user3: '', - user4: '', - user5: '', - user6: '', - user7: '', - user8: '', - user9: '', type: SupportedEvent.Event, revision: 0, timeWarning: 120000, @@ -569,16 +529,6 @@ describe('getDelayAt()', () => { isPublic: true, skip: false, colour: '', - user0: '', - user1: '', - user2: '', - user3: '', - user4: '', - user5: '', - user6: '', - user7: '', - user8: '', - user9: '', type: SupportedEvent.Event, revision: 0, timeWarning: 120000, @@ -608,16 +558,6 @@ describe('getDelayAt()', () => { isPublic: true, skip: false, colour: '', - user0: '', - user1: '', - user2: '', - user3: '', - user4: '', - user5: '', - user6: '', - user7: '', - user8: '', - user9: '', type: SupportedEvent.Event, revision: 0, timeWarning: 120000, @@ -647,16 +587,6 @@ describe('getDelayAt()', () => { isPublic: true, skip: false, colour: '', - user0: '', - user1: '', - user2: '', - user3: '', - user4: '', - user5: '', - user6: '', - user7: '', - user8: '', - user9: '', type: SupportedEvent.Event, revision: 0, timeWarning: 120000, @@ -686,16 +616,6 @@ describe('getDelayAt()', () => { isPublic: true, skip: false, colour: '', - user0: '', - user1: '', - user2: '', - user3: '', - user4: '', - user5: '', - user6: '', - user7: '', - user8: '', - user9: '', type: SupportedEvent.Event, revision: 0, timeWarning: 120000, @@ -751,16 +671,6 @@ describe('calculateRuntimeDelaysFrom()', () => { isPublic: true, skip: false, colour: '', - user0: '', - user1: '', - user2: '', - user3: '', - user4: '', - user5: '', - user6: '', - user7: '', - user8: '', - user9: '', type: SupportedEvent.Event, revision: 0, timeWarning: 120000, @@ -790,16 +700,6 @@ describe('calculateRuntimeDelaysFrom()', () => { isPublic: true, skip: false, colour: '', - user0: '', - user1: '', - user2: '', - user3: '', - user4: '', - user5: '', - user6: '', - user7: '', - user8: '', - user9: '', type: SupportedEvent.Event, revision: 0, timeWarning: 120000, @@ -829,16 +729,6 @@ describe('calculateRuntimeDelaysFrom()', () => { isPublic: true, skip: false, colour: '', - user0: '', - user1: '', - user2: '', - user3: '', - user4: '', - user5: '', - user6: '', - user7: '', - user8: '', - user9: '', type: SupportedEvent.Event, revision: 0, timeWarning: 120000, @@ -868,16 +758,6 @@ describe('calculateRuntimeDelaysFrom()', () => { isPublic: true, skip: false, colour: '', - user0: '', - user1: '', - user2: '', - user3: '', - user4: '', - user5: '', - user6: '', - user7: '', - user8: '', - user9: '', type: SupportedEvent.Event, revision: 0, timeWarning: 120000, diff --git a/apps/server/src/services/rundown-service/rundownCache.ts b/apps/server/src/services/rundown-service/rundownCache.ts index 76cd1d370..2169af053 100644 --- a/apps/server/src/services/rundown-service/rundownCache.ts +++ b/apps/server/src/services/rundown-service/rundownCache.ts @@ -62,6 +62,7 @@ export async function setRundown(initialRundown: OntimeRundown) { generate(); await DataProvider.setRundown(persistedRundown); } + /** * Utility initialises cache * @param rundown @@ -89,10 +90,6 @@ export function generate( // 1. handle links handleLink(i, initialRundown, updatedEvent, links); - // TODO: wait until the next thing? - // update the persisted event - initialRundown[i] = updatedEvent; - // 2. handle custom fields handleCustomField(customFields, customFieldChangelog, updatedEvent, assignedCustomFields); @@ -196,14 +193,12 @@ export function mutateCache(mutation: MutatingFn) { console.timeEnd('rundownCache__init'); }); - // TODO: should we trottle this? + // TODO: should we throttle this? // defer writing to the database setImmediate(() => { - console.log('writing to database', persistedRundown.length); DataProvider.setRundown(persistedRundown); }); - // TODO: could we return a patch object? return { newEvent }; } diff --git a/apps/server/src/services/sheet-service/SheetService.ts b/apps/server/src/services/sheet-service/SheetService.ts index 45a0b7d9d..604840067 100644 --- a/apps/server/src/services/sheet-service/SheetService.ts +++ b/apps/server/src/services/sheet-service/SheetService.ts @@ -4,7 +4,7 @@ * @link https://developers.google.com/identity/protocols/oauth2/limited-input-device */ -import { AuthenticationStatus, LogOrigin, MaybeString, OntimeRundown, UserFields } from 'ontime-types'; +import { AuthenticationStatus, CustomFields, LogOrigin, MaybeString, OntimeRundown } from 'ontime-types'; import { sheets, sheets_v4 } from '@googleapis/sheets'; import { Credentials, OAuth2Client } from 'google-auth-library'; @@ -13,10 +13,10 @@ import got from 'got'; import { resolveSheetsDirectory } from '../../setup.js'; import { ensureDirectory } from '../../utils/fileManagement.js'; import { type ClientSecret, cellRequestFromEvent, getA1Notation, validateClientSecret } from './sheetUtils.js'; -import { ExcelImportMap } from 'ontime-utils'; +import { ImportMap } from 'ontime-utils'; import { parseExcel } from '../../utils/parser.js'; import { logger } from '../../classes/Logger.js'; -import { parseRundown, parseUserFields } from '../../utils/parserFunctions.js'; +import { parseCustomFields, parseRundown } from '../../utils/parserFunctions.js'; import { getRundown } from '../rundown-service/rundownUtils.js'; const sheetScope = 'https://www.googleapis.com/auth/spreadsheets'; @@ -258,7 +258,9 @@ async function verifyWorksheet(sheetId: string, worksheet: string): Promise<{ wo throw new Error(`Request failed: ${spreadsheets.status} ${spreadsheets.statusText}`); } - const selectedWorksheet = spreadsheets.data.sheets.find((n) => n.properties.title == worksheet); + const selectedWorksheet = spreadsheets.data.sheets.find( + (n) => n.properties.title.toLowerCase() === worksheet.toLowerCase(), + ); if (!selectedWorksheet) { throw new Error('Could not find worksheet'); @@ -271,7 +273,7 @@ async function verifyWorksheet(sheetId: string, worksheet: string): Promise<{ wo return { worksheetId: selectedWorksheet.properties.sheetId, range: `${worksheet}!A1:${endCell}` }; } -export async function upload(sheetId: string, options: ExcelImportMap) { +export async function upload(sheetId: string, options: ImportMap) { const { worksheetId, range } = await verifyWorksheet(sheetId, options.worksheet); const readResponse = await sheets({ version: 'v4', auth: currentAuthClient }).spreadsheets.values.get({ @@ -344,10 +346,10 @@ export async function upload(sheetId: string, options: ExcelImportMap) { export async function download( sheetId: string, - options: ExcelImportMap, + options: ImportMap, ): Promise<{ rundown: OntimeRundown; - userFields: UserFields; + customFields: CustomFields; }> { const { range } = await verifyWorksheet(sheetId, options.worksheet); @@ -367,6 +369,6 @@ export async function download( if (rundown.length < 1) { throw new Error('Sheet: Could not find data to import in the worksheet'); } - const userFields = parseUserFields(dataFromSheet); - return { rundown, userFields }; + const customFields = parseCustomFields(dataFromSheet); + return { rundown, customFields }; } diff --git a/apps/server/src/services/sheet-service/__tests__/sheetUtils.test.ts b/apps/server/src/services/sheet-service/__tests__/sheetUtils.test.ts index 126730878..109d6b2b3 100644 --- a/apps/server/src/services/sheet-service/__tests__/sheetUtils.test.ts +++ b/apps/server/src/services/sheet-service/__tests__/sheetUtils.test.ts @@ -37,16 +37,6 @@ describe('cellRequestFromEvent()', () => { isPublic: false, skip: false, colour: 'red', - user0: '', - user1: '', - user2: '', - user3: '', - user4: '', - user5: '', - user6: '', - user7: '', - user8: '', - user9: '', revision: 0, id: '1358', timeWarning: 0, @@ -68,16 +58,6 @@ describe('cellRequestFromEvent()', () => { isPublic: { row: 1, col: 25 }, skip: { row: 1, col: 26 }, colour: { row: 1, col: 27 }, - user0: { row: 1, col: 28 }, - user1: { row: 1, col: 29 }, - user2: { row: 1, col: 30 }, - user3: { row: 1, col: 31 }, - user4: { row: 1, col: 32 }, - user5: { row: 1, col: 33 }, - user6: { row: 1, col: 34 }, - user7: { row: 1, col: 35 }, - user8: { row: 1, col: 36 }, - user9: { row: 1, col: 37 }, revision: { row: 1, col: 38 }, id: { row: 1, col: 39 }, timeWarning: { row: 1, col: 40 }, @@ -87,7 +67,7 @@ describe('cellRequestFromEvent()', () => { expect(result.updateCells.rows[0].values[5].userEnteredValue.stringValue).toStrictEqual(event.note); }); - test('numer to timer', () => { + test('number to timer', () => { const event: OntimeEvent = { type: SupportedEvent.Event, cue: '1', @@ -105,16 +85,6 @@ describe('cellRequestFromEvent()', () => { isPublic: false, skip: false, colour: 'red', - user0: '', - user1: '', - user2: '', - user3: '', - user4: '', - user5: '', - user6: '', - user7: '', - user8: '', - user9: '', revision: 0, id: '1358', timeWarning: 0, @@ -136,16 +106,6 @@ describe('cellRequestFromEvent()', () => { isPublic: { row: 1, col: 25 }, skip: { row: 1, col: 26 }, colour: { row: 1, col: 27 }, - user0: { row: 1, col: 28 }, - user1: { row: 1, col: 29 }, - user2: { row: 1, col: 30 }, - user3: { row: 1, col: 31 }, - user4: { row: 1, col: 32 }, - user5: { row: 1, col: 33 }, - user6: { row: 1, col: 34 }, - user7: { row: 1, col: 35 }, - user8: { row: 1, col: 36 }, - user9: { row: 1, col: 37 }, revision: { row: 1, col: 38 }, id: { row: 1, col: 39 }, timeWarning: { row: 1, col: 40 }, @@ -174,16 +134,6 @@ describe('cellRequestFromEvent()', () => { isPublic: true, skip: false, colour: 'red', - user0: 'u', - user1: 'u', - user2: 'u', - user3: 'u', - user4: 'u', - user5: 'u', - user6: 'u', - user7: 'u', - user8: 'u', - user9: 'u', revision: 0, id: '1358', timeWarning: 0, @@ -205,16 +155,6 @@ describe('cellRequestFromEvent()', () => { isPublic: { row: 1, col: 25 }, skip: { row: 1, col: 26 }, colour: { row: 1, col: 27 }, - user0: { row: 1, col: 28 }, - user1: { row: 1, col: 29 }, - user2: { row: 1, col: 30 }, - user3: { row: 1, col: 31 }, - user4: { row: 1, col: 32 }, - user5: { row: 1, col: 33 }, - user6: { row: 1, col: 34 }, - user7: { row: 1, col: 35 }, - user8: { row: 1, col: 36 }, - user9: { row: 1, col: 37 }, revision: { row: 1, col: 38 }, id: { row: 1, col: 39 }, timeWarning: { row: 1, col: 40 }, @@ -243,16 +183,6 @@ describe('cellRequestFromEvent()', () => { isPublic: true, skip: false, colour: 'red', - user0: 'u', - user1: 'u', - user2: 'u', - user3: 'u', - user4: 'u', - user5: 'u', - user6: 'u', - user7: 'u', - user8: 'u', - user9: 'u', revision: 0, id: '1358', timeWarning: 0, @@ -263,7 +193,6 @@ describe('cellRequestFromEvent()', () => { cue: { row: 1, col: 0 }, title: { row: 1, col: 6 }, subtitle: { row: 1, col: 10 }, - user0: { row: 1, col: 16 }, }; const result = cellRequestFromEvent(event, 1, 1234, metadata); expect(result.updateCells.rows[0].values[0].userEnteredValue.stringValue).toStrictEqual(event.cue); @@ -289,16 +218,6 @@ describe('cellRequestFromEvent()', () => { isPublic: true, skip: false, colour: 'red', - user0: 'u', - user1: 'u', - user2: 'u', - user3: 'u', - user4: 'u', - user5: 'u', - user6: 'u', - user7: 'u', - user8: 'u', - user9: 'u', revision: 0, id: '1358', timeWarning: 0, @@ -335,16 +254,6 @@ describe('cellRequestFromEvent()', () => { isPublic: true, skip: false, colour: 'red', - user0: 'u', - user1: 'u', - user2: 'u', - user3: 'u', - user4: 'u', - user5: 'u', - user6: 'u', - user7: 'u', - user8: 'u', - user9: 'u', revision: 0, id: '1358', timeWarning: 0, @@ -355,7 +264,6 @@ describe('cellRequestFromEvent()', () => { cue: { row: 10, col: 5 }, title: { row: 10, col: 6 }, subtitle: { row: 1, col: 10 }, - user0: { row: 10, col: 16 }, }; const result1 = cellRequestFromEvent(event, 1, 1234, metadata); expect(result1.updateCells.start.sheetId).toStrictEqual(1234); diff --git a/apps/server/src/utils/__tests__/parser.test.ts b/apps/server/src/utils/__tests__/parser.test.ts index 66e07188a..28bedf1c9 100644 --- a/apps/server/src/utils/__tests__/parser.test.ts +++ b/apps/server/src/utils/__tests__/parser.test.ts @@ -1,5 +1,5 @@ /* eslint-disable no-console -- we are mocking the console */ -import { vi } from 'vitest'; +import { assertType, vi } from 'vitest'; import { DatabaseModel, @@ -11,12 +11,14 @@ import { TimeStrategy, TimerType, ViewSettings, + OntimeRundown, } from 'ontime-types'; import { dbModel } from '../../models/dataModel.js'; -import { parseExcel, parseJson, createEvent } from '../parser.js'; + +import { parseExcel, parseJson, createEvent, getCustomFieldData } from '../parser.js'; import { makeString } from '../parserUtils.js'; -import { parseAliases, parseUserFields, parseViewSettings } from '../parserFunctions.js'; +import { parseAliases, parseViewSettings } from '../parserFunctions.js'; describe('test json parser with valid def', () => { const testData: Partial = { @@ -39,16 +41,6 @@ describe('test json parser with valid def', () => { isPublic: false, skip: false, colour: '', - user0: '', - user1: '', - user2: '', - user3: '', - user4: '', - user5: '', - user6: '', - user7: '', - user8: '', - user9: '', revision: 0, timeWarning: 0, timeDanger: 0, @@ -72,16 +64,6 @@ describe('test json parser with valid def', () => { isPublic: true, skip: true, colour: 'red', - user0: '', - user1: '', - user2: '', - user3: '', - user4: '', - user5: '', - user6: '', - user7: '', - user8: '', - user9: '', revision: 0, timeWarning: 0, timeDanger: 0, @@ -105,16 +87,6 @@ describe('test json parser with valid def', () => { isPublic: false, skip: false, colour: '', - user0: '', - user1: '', - user2: '', - user3: '', - user4: '', - user5: '', - user6: '', - user7: '', - user8: '', - user9: '', revision: 0, timeWarning: 0, timeDanger: 0, @@ -159,16 +131,6 @@ describe('test json parser with valid def', () => { isPublic: false, skip: false, colour: '', - user0: '', - user1: '', - user2: '', - user3: '', - user4: '', - user5: '', - user6: '', - user7: '', - user8: '', - user9: '', revision: 0, timeWarning: 0, timeDanger: 0, @@ -192,16 +154,6 @@ describe('test json parser with valid def', () => { isPublic: true, skip: true, colour: '', - user0: '', - user1: '', - user2: '', - user3: '', - user4: '', - user5: '', - user6: '', - user7: '', - user8: '', - user9: '', revision: 0, timeWarning: 0, timeDanger: 0, @@ -223,21 +175,11 @@ describe('test json parser with valid def', () => { duration: 37200000 - 32400000, isPublic: true, colour: '', - user0: '', - user1: '', - user2: '', - user3: '', - user4: '', - user5: '', - user6: '', - user7: '', - user8: '', - user9: '', revision: 0, timeWarning: 0, timeDanger: 0, } as OntimeEvent, - ], + ] as OntimeRundown, project: { title: 'This is a test definition', backstageUrl: 'www.carlosvalente.com', @@ -540,16 +482,7 @@ describe('test event validator', () => { id: expect.any(String), cue: 'test', colour: expect.any(String), - user0: expect.any(String), - user1: expect.any(String), - user2: expect.any(String), - user3: expect.any(String), - user4: expect.any(String), - user5: expect.any(String), - user6: expect.any(String), - user7: expect.any(String), - user8: expect.any(String), - user9: expect.any(String), + custom: expect.any(Object), }), ); }); @@ -600,7 +533,197 @@ describe('test event validator', () => { }); }); -describe('test makeString function', () => { +describe('test aliases import', () => { + it('imports a well defined alias', () => { + const testData = { + rundown: [], + settings: { + app: 'ontime', + version: '2.0.0', + }, + aliases: [ + { + enabled: false, + alias: 'testalias', + pathAndParams: 'testpathAndParams', + }, + ], + }; + + const parsed = parseAliases(testData); + expect(parsed.length).toBe(1); + + // generates missing id + expect(parsed[0].alias).toBeDefined(); + }); +}); + +describe('test views import', () => { + it('imports data from file', () => { + const testData = { + rundown: [], + settings: { + app: 'ontime', + version: '2.0.0', + }, + viewSettings: { + normalColor: '#ffffffcc', + warningColor: '#FFAB33', + dangerColor: '#ED3333', + endMessage: '', + overrideStyles: false, + notAthing: true, + }, + views: { + overrideStyles: true, + }, + }; + const expectedParsedViewSettings = { + normalColor: '#ffffffcc', + warningColor: '#FFAB33', + dangerColor: '#ED3333', + endMessage: '', + overrideStyles: false, + }; + const parsed = parseViewSettings(testData); + expect(parsed).toStrictEqual(expectedParsedViewSettings); + }); + + it('imports defaults to model', () => { + const testData = { + rundown: [], + settings: { + app: 'ontime', + version: '2.0.0', + }, + }; + const parsed = parseViewSettings(testData); + expect(parsed).toStrictEqual({}); + }); +}); + +describe('test import of v2 datamodel', () => { + it('ignores deprecated fields and generates new ones', async () => { + const v2ProjectFile = { + rundown: [ + { type: SupportedEvent.Block, title: 'block-title', id: 'block-id' }, + { type: SupportedEvent.Delay, duration: 0 }, + { type: SupportedEvent.Event, title: 'block-title', id: 'block-id' }, + ], + project: { + title: '', + description: '', + publicUrl: '', + publicInfo: '', + backstageUrl: '', + backstageInfo: '', + }, + settings: { + app: 'ontime', + version: '2.0.0', + serverPort: 4001, + editorKey: null, + operatorKey: null, + timeFormat: '24', + language: 'en', + }, + viewSettings: { + overrideStyles: false, + normalColor: '#ffffffcc', + warningColor: '#FFAB33', + dangerColor: '#ED3333', + endMessage: '', + }, + aliases: [], + userFields: { + user0: 'user0', + user1: 'user1', + user2: 'user2', + user3: 'user3', + user4: 'user4', + user5: 'user5', + user6: 'user6', + user7: 'user7', + user8: 'user8', + user9: 'user9', + }, + osc: { + portIn: 8888, + portOut: 9999, + targetIP: '127.0.0.1', + enabledIn: false, + enabledOut: false, + subscriptions: { + onLoad: [], + onStart: [], + onPause: [], + onStop: [], + onUpdate: [], + onFinish: [], + }, + }, + http: { + enabledOut: false, + subscriptions: { + onLoad: [], + onStart: [], + onPause: [], + onStop: [], + onUpdate: [], + onFinish: [], + }, + }, + }; + // @ts-expect-error -- we know this is wrong, testing imports outside domain + const parsed = await parseJson(v2ProjectFile); + expect(parsed.rundown.length).toBe(3); + expect(parsed.rundown[0]).toMatchObject({ type: SupportedEvent.Block }); + expect(parsed.rundown[0]).toEqual( + expect.objectContaining({ + id: expect.any(String), + title: expect.any(String), + }), + ); + expect(parsed.rundown[1]).toMatchObject({ type: SupportedEvent.Delay }); + expect(parsed.rundown[1]).toEqual( + expect.objectContaining({ + id: expect.any(String), + duration: expect.any(Number), + }), + ); + expect(parsed.rundown[2]).toMatchObject({ type: SupportedEvent.Event }); + expect(parsed.rundown[2]).toEqual( + expect.objectContaining({ + id: expect.any(String), + cue: expect.any(String), + title: expect.any(String), + subtitle: expect.any(String), + presenter: expect.any(String), + note: expect.any(String), + endAction: expect.any(String), + timerType: expect.any(String), + linkStart: null, + timeStrategy: expect.any(String), + timeStart: expect.any(Number), + timeEnd: expect.any(Number), + duration: expect.any(Number), + isPublic: expect.any(Boolean), + skip: expect.any(Boolean), + colour: expect.any(String), + revision: expect.any(Number), + timeWarning: expect.any(Number), + timeDanger: expect.any(Number), + custom: expect.any(Object), + }), + ); + // @ts-expect-error -- checking if the field is removed + expect(parsed?.userFields).toBeUndefined(); + expect(parsed.osc).toMatchObject({ subscriptions: [] }); + expect(parsed.http).toMatchObject({ enabledOut: false, subscriptions: [] }); + }); +}); + +describe('makeString()', () => { it('converts variables to string', () => { let val = 2; let expected = '2'; @@ -625,7 +748,61 @@ describe('test makeString function', () => { }); }); -describe('test parseExcel function', () => { +describe('getCustomFieldData()', () => { + it('generates a list of keys from the given import map', () => { + const importMap = { + worksheet: 'event schedule', + timeStart: 'time start', + timeEnd: 'time end', + duration: 'duration', + cue: 'cue', + title: 'title', + presenter: 'presenter', + subtitle: 'subtitle', + isPublic: 'public', + skip: 'skip', + note: 'notes', + colour: 'colour', + endAction: 'end action', + timerType: 'timer type', + timeWarning: 'warning time', + timeDanger: 'danger time', + custom: { + lighting: 'lx', + sound: 'sound', + video: 'av', + }, + }; + + const result = getCustomFieldData(importMap); + expect(result.customFields).toStrictEqual({ + lighting: { + type: 'string', + colour: '', + label: 'lighting', + }, + sound: { + type: 'string', + colour: '', + label: 'sound', + }, + video: { + type: 'string', + colour: '', + label: 'video', + }, + }); + + // it is an inverted record of + expect(result.customFieldImportKeys).toStrictEqual({ + lx: 'lighting', + sound: 'sound', + av: 'video', + }); + }); +}); + +describe('parseExcel()', () => { it('parses the example file', async () => { const testdata = [ ['Ontime ┬À Schedule Template'], @@ -705,17 +882,20 @@ describe('test parseExcel function', () => { [], ]; - const partialOptions = { - user0: 'test0', - user1: 'test1', - user2: 'test2', - user3: 'test3', - user4: 'test4', - user5: 'test5', - user6: 'test6', - user7: 'test7', - user8: 'test8', - user9: 'test9', + // partial import map with only custom fields + const importMap = { + custom: { + user0: 'test0', + user1: 'test1', + user2: 'test2', + user3: 'test3', + user4: 'test4', + user5: 'test5', + user6: 'test6', + user7: 'test7', + user8: 'test8', + user9: 'test9', + }, }; // TODO: update tests once import is resolved @@ -731,16 +911,18 @@ describe('test parseExcel function', () => { isPublic: true, skip: false, note: 'Ballyhoo', - user0: 'a0', - user1: 'a1', - user2: 'a2', - user3: 'a3', - user4: 'a4', - user5: 'a5', - user6: 'a6', - user7: 'a7', - user8: 'a8', - user9: 'a9', + custom: { + user0: { value: 'a0' }, + user1: { value: 'a1' }, + user2: { value: 'a2' }, + user3: { value: 'a3' }, + user4: { value: 'a4' }, + user5: { value: 'a5' }, + user6: { value: 'a6' }, + user7: { value: 'a7' }, + user8: { value: 'a8' }, + user9: { value: 'a9' }, + }, colour: 'red', type: 'event', cue: '101', @@ -756,174 +938,214 @@ describe('test parseExcel function', () => { isPublic: false, skip: true, note: 'Rainbow chase', - user0: 'b0', - user5: 'b5', + custom: { + user0: { value: 'b0' }, + user5: { value: 'b5' }, + }, colour: '#F00', type: 'event', cue: '102', }, ]; - const parsedData = parseExcel(testdata, partialOptions); - expect(parsedData.rundown).toBeDefined(); + const parsedData = parseExcel(testdata, importMap); + expect(parsedData.customFields).toStrictEqual({ + user0: { + type: 'string', + colour: '', + label: 'user0', + }, + user1: { + type: 'string', + colour: '', + label: 'user1', + }, + user2: { + type: 'string', + colour: '', + label: 'user2', + }, + user3: { + type: 'string', + colour: '', + label: 'user3', + }, + user4: { + type: 'string', + colour: '', + label: 'user4', + }, + user5: { + type: 'string', + colour: '', + label: 'user5', + }, + user6: { + type: 'string', + colour: '', + label: 'user6', + }, + user7: { + type: 'string', + colour: '', + label: 'user7', + }, + user8: { + type: 'string', + colour: '', + label: 'user8', + }, + user9: { + type: 'string', + colour: '', + label: 'user9', + }, + }); + expect(parsedData.rundown.length).toBe(2); expect(parsedData.rundown[0]).toMatchObject(expectedParsedRundown[0]); expect(parsedData.rundown[1]).toMatchObject(expectedParsedRundown[1]); }); -}); -describe('test aliases import', () => { - it('imports a well defined alias', () => { - const testData = { - rundown: [], - settings: { - app: 'ontime', - version: '2.0.0', - }, - aliases: [ - { - enabled: false, - alias: 'testalias', - pathAndParams: 'testpathAndParams', - }, + it('parses a file without custom fields', async () => { + const testdata = [ + ['Ontime ┬À Schedule Template'], + [], + [ + 'Time Start', + 'Time End', + 'Title', + 'Presenter', + 'Subtitle', + 'End Action', + 'Timer type', + 'Public', + 'Skip', + 'Notes', + 'test0', + 'test1', + 'test2', + 'test3', + 'test4', + 'test5', + 'test6', + 'test7', + 'test8', + 'test9', + 'Colour', + 'cue', ], + [ + '1899-12-30T07:00:00.000Z', + '1899-12-30T08:00:10.000Z', + 'Guest Welcome', + 'Carlos', + 'Getting things started', + '', + '', + 'x', + '', + 'Ballyhoo', + 'a0', + 'a1', + 'a2', + 'a3', + 'a4', + 'a5', + 'a6', + 'a7', + 'a8', + 'a9', + 'red', + 101, + ], + [ + '1899-12-30T08:00:00.000Z', + '1899-12-30T08:30:00.000Z', + 'A song from the hearth', + 'Still Carlos', + 'Derailing early', + 'load-next', + 'clock', + '', + 'x', + 'Rainbow chase', + 'b0', + '', + '', + '', + '', + 'b5', + '', + '', + '', + '', + '#F00', + 102, + ], + [], + ]; + + // partial import map with only custom fields + const importMap = { + custom: { + niu1: 'niu1', + niu2: 'niu2', + }, }; - const parsed = parseAliases(testData); - expect(parsed.length).toBe(1); + // TODO: update tests once import is resolved + const expectedParsedRundown = [ + { + //timeStart: 28800000, + //timeEnd: 32410000, + title: 'Guest Welcome', + presenter: 'Carlos', + subtitle: 'Getting things started', + timerType: 'count-down', + endAction: 'none', + isPublic: true, + skip: false, + note: 'Ballyhoo', + custom: {}, + colour: 'red', + type: 'event', + cue: '101', + }, + { + //timeStart: 32400000, + //timeEnd: 34200000, + title: 'A song from the hearth', + presenter: 'Still Carlos', + subtitle: 'Derailing early', + timerType: 'clock', + endAction: 'load-next', + isPublic: false, + skip: true, + note: 'Rainbow chase', + custom: {}, + colour: '#F00', + type: 'event', + cue: '102', + }, + ]; - // generates missing id - expect(parsed[0].alias).toBeDefined(); - }); -}); - -describe('test userFields import', () => { - const model = dbModel.userFields; - it('imports a fully defined user fields', () => { - const testUserFields = { - user0: 'test0', - user1: 'test1', - user2: 'test2', - user3: 'test3', - user4: 'test4', - user5: 'test5', - user6: 'test6', - user7: 'test7', - user8: 'test8', - user9: 'test9', - }; - - const testData = { - rundown: [], - settings: { - app: 'ontime', - version: '2.0.0', - }, - userFields: testUserFields, - }; - - const parsed = parseUserFields(testData); - expect(parsed).toStrictEqual(testUserFields); - }); - - it('imports a partially defined user fields', () => { - const testUserFields = { - user0: 'test0', - user1: 'test1', - user7: 'test7', - user8: 'test8', - user9: 'test9', - }; - - const expected = { - ...model, - ...testUserFields, - }; - - const testData = { - rundown: [], - settings: { - app: 'ontime', - version: '2.0.0', - }, - userFields: testUserFields, - }; - - const parsed = parseUserFields(testData); - expect(parsed).toStrictEqual(expected); - }); - - it('handles missing user fields', () => { - const testData = { - rundown: [], - settings: { - app: 'ontime', - version: '2.0.0', - }, - }; - - const parsed = parseUserFields(testData); - expect(parsed).toStrictEqual(model); - expect(parsed).toStrictEqual(model); - }); - - it('ignores badly defined fields', () => { - const testData = { - rundown: [], - settings: { - app: 'ontime', - version: '2.0.0', - }, - userFields: { - notThis: 'this shouldng be accepted', - orThis: 'this neither', - }, - }; - - const parsed = parseUserFields(testData); - expect(parsed).toStrictEqual(model); - }); -}); - -describe('test views import', () => { - it('imports data from file', () => { - const testData = { - rundown: [], - settings: { - app: 'ontime', - version: '2.0.0', - }, - viewSettings: { - normalColor: '#ffffffcc', - warningColor: '#FFAB33', - dangerColor: '#ED3333', - endMessage: '', - overrideStyles: false, - notAthing: true, - }, - views: { - overrideStyles: true, - }, - }; - const expectedParsedViewSettings = { - normalColor: '#ffffffcc', - warningColor: '#FFAB33', - dangerColor: '#ED3333', - endMessage: '', - overrideStyles: false, - }; - const parsed = parseViewSettings(testData); - expect(parsed).toStrictEqual(expectedParsedViewSettings); - }); - - it('imports defaults to model', () => { - const testData = { - rundown: [], - settings: { - app: 'ontime', - version: '2.0.0', - }, - }; - const parsed = parseViewSettings(testData); - expect(parsed).toStrictEqual({}); + const parsedData = parseExcel(testdata, importMap); + expect(parsedData.customFields).toStrictEqual({ + niu1: { + type: 'string', + colour: '', + label: 'niu1', + }, + niu2: { + type: 'string', + colour: '', + label: 'niu2', + }, + }); + expect(parsedData.rundown.length).toBe(2); + expect(parsedData.rundown[0]).toMatchObject(expectedParsedRundown[0]); + expect(parsedData.rundown[1]).toMatchObject(expectedParsedRundown[1]); }); + + it.todo('imports events and blocks, ignores otherwise', () => {}); }); diff --git a/apps/server/src/utils/parser.ts b/apps/server/src/utils/parser.ts index 15edff11c..7f8998514 100644 --- a/apps/server/src/utils/parser.ts +++ b/apps/server/src/utils/parser.ts @@ -1,11 +1,11 @@ import { generateId, - isExcelImportMap, - type ExcelImportMap, - defaultExcelImportMap, + isImportMap, + type ImportMap, + defaultImportMap, validateEndAction, validateTimerType, - type ExcelImportOptions, + type ImportOptions, validateTimes, isKnownTimerType, validateLinkStart, @@ -15,10 +15,11 @@ import { OntimeEvent, OntimeRundown, SupportedEvent, - UserFields, EndAction, TimerType, TimeStrategy, + CustomFields, + EventCustomFields, } from 'ontime-types'; import fs from 'fs'; @@ -35,7 +36,6 @@ import { parseHttp, parseRundown, parseSettings, - parseUserFields, parseViewSettings, parseCustomFields, } from './parserFunctions.js'; @@ -46,34 +46,46 @@ import { coerceBoolean } from './coerceType.js'; export const EXCEL_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'; export const JSON_MIME = 'application/json'; -type ExcelData = Pick & { +type ExcelData = Pick & { rundownMetadata: Record; }; +export function getCustomFieldData(importMap: ImportMap): { + customFields: CustomFields; + customFieldImportKeys: Record; +} { + const customFields = {}; + const customFieldImportKeys = {}; + for (const key in importMap.custom) { + const ontimeName = key; + const importName = importMap.custom[key]; + customFields[ontimeName] = { + type: 'string', + colour: '', + label: ontimeName, + }; + customFieldImportKeys[importName] = ontimeName; + } + return { customFields, customFieldImportKeys }; +} + /** * @description Excel array parser * @param {array} excelData - array with excel sheet - * @param {ExcelImportOptions} options - an object that contains the import map + * @param {ImportOptions} options - an object that contains the import map * @returns {object} - parsed object */ -export const parseExcel = (excelData: unknown[][], options?: Partial): ExcelData => { +export const parseExcel = (excelData: unknown[][], options?: Partial): ExcelData => { const rundownMetadata = {}; - const importMap: ExcelImportMap = { ...defaultExcelImportMap, ...options }; + const importMap: ImportMap = { ...defaultImportMap, ...options }; + for (const [key, value] of Object.entries(importMap)) { - importMap[key] = value.toLocaleLowerCase(); + if (typeof value === 'string') { + importMap[key] = value.toLocaleLowerCase(); + } } - const customUserFields: UserFields = { - user0: importMap.user0, - user1: importMap.user1, - user2: importMap.user2, - user3: importMap.user3, - user4: importMap.user4, - user5: importMap.user5, - user6: importMap.user6, - user7: importMap.user7, - user8: importMap.user8, - user9: importMap.user9, - }; + + const { customFields, customFieldImportKeys } = getCustomFieldData(importMap); const rundown: OntimeRundown = []; // title stuff: strings @@ -99,23 +111,15 @@ export const parseExcel = (excelData: unknown[][], options?: Partial = {}; excelData.forEach((row, rowIndex) => { if (row.length === 0) { return; } + // TODO: extract generating handlers from importMap const handlers = { [importMap.timeStart]: (row: number, col: number) => { timeStartIndex = col; @@ -162,7 +166,6 @@ export const parseExcel = (excelData: unknown[][], options?: Partial { endActionIndex = col; rundownMetadata['endAction'] = { row, col }; @@ -179,50 +182,15 @@ export const parseExcel = (excelData: unknown[][], options?: Partial { - user0Index = col; - rundownMetadata['user0'] = { row, col }; - }, - [importMap.user1]: (row: number, col: number) => { - user1Index = col; - rundownMetadata['user1'] = { row, col }; - }, - [importMap.user2]: (row: number, col: number) => { - user2Index = col; - rundownMetadata['user2'] = { row, col }; - }, - [importMap.user3]: (row: number, col: number) => { - user3Index = col; - rundownMetadata['user3'] = { row, col }; - }, - [importMap.user4]: (row: number, col: number) => { - user4Index = col; - rundownMetadata['user4'] = { row, col }; - }, - [importMap.user5]: (row: number, col: number) => { - user5Index = col; - rundownMetadata['user5'] = { row, col }; - }, - [importMap.user6]: (row: number, col: number) => { - user6Index = col; - rundownMetadata['user6'] = { row, col }; - }, - [importMap.user7]: (row: number, col: number) => { - user7Index = col; - rundownMetadata['user7'] = { row, col }; - }, - [importMap.user8]: (row: number, col: number) => { - user8Index = col; - rundownMetadata['user8'] = { row, col }; - }, - [importMap.user9]: (row: number, col: number) => { - user9Index = col; - rundownMetadata['user9'] = { row, col }; + custom: (row: number, col: number, columnText: string) => { + customFieldIndexes[col] = columnText; + rundownMetadata[`custom-${columnText}`] = { row, col }; }, } as const; const event: any = {}; + const eventCustomFields: EventCustomFields = {}; + row.forEach((column, j) => { // 1. we check if we have set a flag for a known field if (j === timerTypeIndex) { @@ -267,49 +235,45 @@ export const parseExcel = (excelData: unknown[][], options?: Partial 0) { - // if any data was found, push to array - rundown.push({ ...event }); + // if any data was found in row, push to array + const keysFound = Object.keys(event).length + Object.keys(eventCustomFields).length; + if (keysFound > 0) { + rundown.push({ ...event, custom: { ...eventCustomFields } }); } }); return { rundown, - userFields: customUserFields, + customFields, rundownMetadata, }; }; @@ -330,7 +294,6 @@ export const parseJson = async (jsonData: Partial): Promise> => { +export const fileHandler = async (file: string, options: ImportOptions): Promise> => { const res: Partial = {}; const fileName = path.basename(file); @@ -444,8 +397,8 @@ export const fileHandler = async (file: string, options: ExcelImportOptions): Pr // check which file type are we dealing with if (file.endsWith('.xlsx')) { // we need to check that the options are applicable - if (!isExcelImportMap(options)) { - throw new Error('Got incorrect options to excel import'); + if (!isImportMap(options)) { + throw new Error('Got incorrect options for spreadsheet import'); } const excelData = xlsx @@ -461,9 +414,9 @@ export const fileHandler = async (file: string, options: ExcelImportOptions): Pr res.data = {}; res.data.rundown = parseRundown(dataFromExcel); if (res.data.rundown.length < 1) { - throw new Error(`Could not find data to import in the worksheet ${options.worksheet}`); + throw new Error(`Could not find data to import in the worksheet: ${options.worksheet}`); } - res.data.userFields = parseUserFields(dataFromExcel); + res.data.customFields = parseCustomFields(dataFromExcel); deleteFile(file); diff --git a/apps/server/src/utils/parserFunctions.ts b/apps/server/src/utils/parserFunctions.ts index 384a21aa4..abb3c0228 100644 --- a/apps/server/src/utils/parserFunctions.ts +++ b/apps/server/src/utils/parserFunctions.ts @@ -6,7 +6,6 @@ import { OSCSettings, ProjectData, Settings, - UserFields, ViewSettings, OscSubscription, DatabaseModel, @@ -251,33 +250,6 @@ export const parseAliases = (data): Alias[] => { return newAliases; }; -/** - * Parse userFields entry - * @param {object} data - data object - * @returns {object} - event object data - */ -export const parseUserFields = (data): UserFields => { - const newUserFields: UserFields = { ...dbModel.userFields }; - - if ('userFields' in data) { - console.log('Found User Fields definition, importing...'); - // we will only be importing the fields we know, so look for that - try { - let fieldsFound = 0; - for (const n in newUserFields) { - if (n in data.userFields) { - fieldsFound++; - newUserFields[n] = data.userFields[n]; - } - } - console.log(`Uploaded ${fieldsFound} user fields`); - } catch (error) { - console.log(`Error: ${error}`); - } - } - return { ...newUserFields }; -}; - /** * Parse customFields entry * @param {object} data - data object diff --git a/apps/server/test-db/db.json b/apps/server/test-db/db.json index 7daca5e61..e913c709a 100644 --- a/apps/server/test-db/db.json +++ b/apps/server/test-db/db.json @@ -13,20 +13,11 @@ "isPublic": true, "skip": false, "colour": "", - "user0": "", - "user1": "", - "user2": "", - "user3": "", - "user4": "", - "user5": "", - "user6": "", - "user7": "", - "user8": "", - "user9": "", "type": "event", "revision": 0, "id": "32d31", - "cue": "SF1.01" + "cue": "SF1.01", + "custom": {} }, { "title": "Latvia", @@ -41,20 +32,11 @@ "isPublic": true, "skip": false, "colour": "", - "user0": "", - "user1": "", - "user2": "", - "user3": "", - "user4": "", - "user5": "", - "user6": "", - "user7": "", - "user8": "", - "user9": "", "type": "event", "revision": 0, "id": "21cd2", - "cue": "SF1.02" + "cue": "SF1.02", + "custom": {} }, { "title": "Lithuania", @@ -69,16 +51,6 @@ "isPublic": true, "skip": false, "colour": "", - "user0": "", - "user1": "", - "user2": "", - "user3": "", - "user4": "", - "user5": "", - "user6": "", - "user7": "", - "user8": "", - "user9": "", "type": "event", "revision": 0, "id": "0b371", @@ -97,20 +69,11 @@ "isPublic": true, "skip": false, "colour": "", - "user0": "", - "user1": "", - "user2": "", - "user3": "", - "user4": "", - "user5": "", - "user6": "", - "user7": "", - "user8": "", - "user9": "", "type": "event", "revision": 0, "id": "3cd28", - "cue": "SF1.04" + "cue": "SF1.04", + "custom": {} }, { "title": "Slovenia", @@ -125,20 +88,11 @@ "isPublic": true, "skip": false, "colour": "", - "user0": "", - "user1": "", - "user2": "", - "user3": "", - "user4": "", - "user5": "", - "user6": "", - "user7": "", - "user8": "", - "user9": "", "type": "event", "revision": 0, "id": "e457f", - "cue": "SF1.05" + "cue": "SF1.05", + "custom": {} }, { "title": "Lunch break", @@ -158,20 +112,11 @@ "isPublic": true, "skip": false, "colour": "", - "user0": "", - "user1": "", - "user2": "", - "user3": "", - "user4": "", - "user5": "", - "user6": "", - "user7": "", - "user8": "", - "user9": "", "type": "event", "revision": 0, "id": "1c420", - "cue": "SF1.06" + "cue": "SF1.06", + "custom": {} }, { "title": "Bulgaria", @@ -186,20 +131,11 @@ "isPublic": true, "skip": false, "colour": "", - "user0": "", - "user1": "", - "user2": "", - "user3": "", - "user4": "", - "user5": "", - "user6": "", - "user7": "", - "user8": "", - "user9": "", "type": "event", "revision": 0, "id": "b7737", - "cue": "SF1.07" + "cue": "SF1.07", + "custom": {} }, { "title": "Netherlands", @@ -214,20 +150,11 @@ "isPublic": true, "skip": false, "colour": "", - "user0": "", - "user1": "", - "user2": "", - "user3": "", - "user4": "", - "user5": "", - "user6": "", - "user7": "", - "user8": "", - "user9": "", "type": "event", "revision": 0, "id": "d3a80", - "cue": "SF1.08" + "cue": "SF1.08", + "custom": {} }, { "title": "Moldova", @@ -242,20 +169,11 @@ "isPublic": true, "skip": false, "colour": "", - "user0": "", - "user1": "", - "user2": "", - "user3": "", - "user4": "", - "user5": "", - "user6": "", - "user7": "", - "user8": "", - "user9": "", "type": "event", "revision": 0, "id": "8276c", - "cue": "SF1.09" + "cue": "SF1.09", + "custom": {} }, { "title": "Portugal", @@ -270,20 +188,11 @@ "isPublic": true, "skip": false, "colour": "", - "user0": "", - "user1": "", - "user2": "", - "user3": "", - "user4": "", - "user5": "", - "user6": "", - "user7": "", - "user8": "", - "user9": "", "type": "event", "revision": 0, "id": "2340b", - "cue": "SF1.10" + "cue": "SF1.10", + "custom": {} }, { "title": "Afternoon break", @@ -303,20 +212,11 @@ "isPublic": true, "skip": false, "colour": "", - "user0": "", - "user1": "", - "user2": "", - "user3": "", - "user4": "", - "user5": "", - "user6": "", - "user7": "", - "user8": "", - "user9": "", "type": "event", "revision": 0, "id": "503c4", - "cue": "SF1.11" + "cue": "SF1.11", + "custom": {} }, { "title": "Denmark", @@ -331,20 +231,11 @@ "isPublic": true, "skip": false, "colour": "", - "user0": "", - "user1": "", - "user2": "", - "user3": "", - "user4": "", - "user5": "", - "user6": "", - "user7": "", - "user8": "", - "user9": "", "type": "event", "revision": 0, "id": "5e965", - "cue": "SF1.12" + "cue": "SF1.12", + "custom": {} }, { "title": "Austria", @@ -359,20 +250,11 @@ "isPublic": true, "skip": false, "colour": "", - "user0": "", - "user1": "", - "user2": "", - "user3": "", - "user4": "", - "user5": "", - "user6": "", - "user7": "", - "user8": "", - "user9": "", "type": "event", "revision": 0, "id": "bab4a", - "cue": "SF1.13" + "cue": "SF1.13", + "custom": {} }, { "title": "Greece", @@ -387,20 +269,11 @@ "isPublic": true, "skip": false, "colour": "", - "user0": "", - "user1": "", - "user2": "", - "user3": "", - "user4": "", - "user5": "", - "user6": "", - "user7": "", - "user8": "", - "user9": "", "type": "event", "revision": 0, "id": "d3eb1", - "cue": "SF1.14" + "cue": "SF1.14", + "custom": {} } ], "project": { @@ -436,18 +309,6 @@ "pathAndParams": "lower?bg=ff2&text=f00&size=0.6&transition=5" } ], - "userFields": { - "user0": "user0", - "user1": "user1", - "user2": "user2", - "user3": "user3", - "user4": "user4", - "user5": "user5", - "user6": "user6", - "user7": "user7", - "user8": "user8", - "user9": "user9" - }, "osc": { "portIn": 8888, "portOut": 9999, diff --git a/demo-db/db.json b/demo-db/db.json index 01ff47a36..d46b129d3 100644 --- a/demo-db/db.json +++ b/demo-db/db.json @@ -13,20 +13,11 @@ "isPublic": true, "skip": false, "colour": "", - "user0": "", - "user1": "", - "user2": "", - "user3": "", - "user4": "", - "user5": "", - "user6": "", - "user7": "", - "user8": "", - "user9": "", "type": "event", "revision": 0, "id": "32d31", - "cue": "SF1.01" + "cue": "SF1.01", + "custom": {} }, { "title": "Latvia", @@ -41,20 +32,11 @@ "isPublic": true, "skip": false, "colour": "", - "user0": "", - "user1": "", - "user2": "", - "user3": "", - "user4": "", - "user5": "", - "user6": "", - "user7": "", - "user8": "", - "user9": "", "type": "event", "revision": 0, "id": "21cd2", - "cue": "SF1.02" + "cue": "SF1.02", + "custom": {} }, { "title": "Lithuania", @@ -69,20 +51,11 @@ "isPublic": true, "skip": false, "colour": "", - "user0": "", - "user1": "", - "user2": "", - "user3": "", - "user4": "", - "user5": "", - "user6": "", - "user7": "", - "user8": "", - "user9": "", "type": "event", "revision": 0, "id": "0b371", - "cue": "SF1.03" + "cue": "SF1.03", + "custom": {} }, { "title": "Switzerland", @@ -97,20 +70,11 @@ "isPublic": true, "skip": false, "colour": "", - "user0": "", - "user1": "", - "user2": "", - "user3": "", - "user4": "", - "user5": "", - "user6": "", - "user7": "", - "user8": "", - "user9": "", "type": "event", "revision": 0, "id": "3cd28", - "cue": "SF1.04" + "cue": "SF1.04", + "custom": {} }, { "title": "Slovenia", @@ -125,20 +89,11 @@ "isPublic": true, "skip": false, "colour": "", - "user0": "", - "user1": "", - "user2": "", - "user3": "", - "user4": "", - "user5": "", - "user6": "", - "user7": "", - "user8": "", - "user9": "", "type": "event", "revision": 0, "id": "e457f", - "cue": "SF1.05" + "cue": "SF1.05", + "custom": {} }, { "title": "Lunch break", @@ -158,20 +113,11 @@ "isPublic": true, "skip": false, "colour": "", - "user0": "", - "user1": "", - "user2": "", - "user3": "", - "user4": "", - "user5": "", - "user6": "", - "user7": "", - "user8": "", - "user9": "", "type": "event", "revision": 0, "id": "1c420", - "cue": "SF1.06" + "cue": "SF1.06", + "custom": {} }, { "title": "Bulgaria", @@ -186,20 +132,11 @@ "isPublic": true, "skip": false, "colour": "", - "user0": "", - "user1": "", - "user2": "", - "user3": "", - "user4": "", - "user5": "", - "user6": "", - "user7": "", - "user8": "", - "user9": "", "type": "event", "revision": 0, "id": "b7737", - "cue": "SF1.07" + "cue": "SF1.07", + "custom": {} }, { "title": "Netherlands", @@ -214,20 +151,11 @@ "isPublic": true, "skip": false, "colour": "", - "user0": "", - "user1": "", - "user2": "", - "user3": "", - "user4": "", - "user5": "", - "user6": "", - "user7": "", - "user8": "", - "user9": "", "type": "event", "revision": 0, "id": "d3a80", - "cue": "SF1.08" + "cue": "SF1.08", + "custom": {} }, { "title": "Moldova", @@ -242,20 +170,11 @@ "isPublic": true, "skip": false, "colour": "", - "user0": "", - "user1": "", - "user2": "", - "user3": "", - "user4": "", - "user5": "", - "user6": "", - "user7": "", - "user8": "", - "user9": "", "type": "event", "revision": 0, "id": "8276c", - "cue": "SF1.09" + "cue": "SF1.09", + "custom": {} }, { "title": "Portugal", @@ -270,20 +189,11 @@ "isPublic": true, "skip": false, "colour": "", - "user0": "", - "user1": "", - "user2": "", - "user3": "", - "user4": "", - "user5": "", - "user6": "", - "user7": "", - "user8": "", - "user9": "", "type": "event", "revision": 0, "id": "2340b", - "cue": "SF1.10" + "cue": "SF1.10", + "custom": {} }, { "title": "Afternoon break", @@ -303,20 +213,11 @@ "isPublic": true, "skip": false, "colour": "", - "user0": "", - "user1": "", - "user2": "", - "user3": "", - "user4": "", - "user5": "", - "user6": "", - "user7": "", - "user8": "", - "user9": "", "type": "event", "revision": 0, "id": "503c4", - "cue": "SF1.11" + "cue": "SF1.11", + "custom": {} }, { "title": "Denmark", @@ -331,20 +232,11 @@ "isPublic": true, "skip": false, "colour": "", - "user0": "", - "user1": "", - "user2": "", - "user3": "", - "user4": "", - "user5": "", - "user6": "", - "user7": "", - "user8": "", - "user9": "", "type": "event", "revision": 0, "id": "5e965", - "cue": "SF1.12" + "cue": "SF1.12", + "custom": {} }, { "title": "Austria", @@ -359,20 +251,11 @@ "isPublic": true, "skip": false, "colour": "", - "user0": "", - "user1": "", - "user2": "", - "user3": "", - "user4": "", - "user5": "", - "user6": "", - "user7": "", - "user8": "", - "user9": "", "type": "event", "revision": 0, "id": "bab4a", - "cue": "SF1.13" + "cue": "SF1.13", + "custom": {} }, { "title": "Greece", @@ -387,20 +270,11 @@ "isPublic": true, "skip": false, "colour": "", - "user0": "", - "user1": "", - "user2": "", - "user3": "", - "user4": "", - "user5": "", - "user6": "", - "user7": "", - "user8": "", - "user9": "", "type": "event", "revision": 0, "id": "d3eb1", - "cue": "SF1.14" + "cue": "SF1.14", + "custom": {} } ], "project": { @@ -436,18 +310,6 @@ "pathAndParams": "lower?bg=ff2&text=f00&size=0.6&transition=5" } ], - "userFields": { - "user0": "user0", - "user1": "user1", - "user2": "user2", - "user3": "user3", - "user4": "user4", - "user5": "user5", - "user6": "user6", - "user7": "user7", - "user8": "user8", - "user9": "user9" - }, "osc": { "portIn": 8888, "portOut": 9999, diff --git a/e2e/tests/002-upload-showfile.spec.ts b/e2e/tests/002-upload-showfile.spec.ts index 523ad24cc..a56aaffed 100644 --- a/e2e/tests/002-upload-showfile.spec.ts +++ b/e2e/tests/002-upload-showfile.spec.ts @@ -13,7 +13,7 @@ test('test project file upload', async ({ page }) => { // https://playwright.dev/docs/api/class-filechooser const [fileChooser] = await Promise.all([ page.waitForEvent('filechooser'), - await page.getByText('Click to select Ontime project or xlsx rundown').click(), + await page.getByText('Click to select Ontime project').click(), ]); await fileChooser.setFiles(fileToUpload); diff --git a/e2e/tests/fixtures/test-db.json b/e2e/tests/fixtures/test-db.json index 01ff47a36..d46b129d3 100644 --- a/e2e/tests/fixtures/test-db.json +++ b/e2e/tests/fixtures/test-db.json @@ -13,20 +13,11 @@ "isPublic": true, "skip": false, "colour": "", - "user0": "", - "user1": "", - "user2": "", - "user3": "", - "user4": "", - "user5": "", - "user6": "", - "user7": "", - "user8": "", - "user9": "", "type": "event", "revision": 0, "id": "32d31", - "cue": "SF1.01" + "cue": "SF1.01", + "custom": {} }, { "title": "Latvia", @@ -41,20 +32,11 @@ "isPublic": true, "skip": false, "colour": "", - "user0": "", - "user1": "", - "user2": "", - "user3": "", - "user4": "", - "user5": "", - "user6": "", - "user7": "", - "user8": "", - "user9": "", "type": "event", "revision": 0, "id": "21cd2", - "cue": "SF1.02" + "cue": "SF1.02", + "custom": {} }, { "title": "Lithuania", @@ -69,20 +51,11 @@ "isPublic": true, "skip": false, "colour": "", - "user0": "", - "user1": "", - "user2": "", - "user3": "", - "user4": "", - "user5": "", - "user6": "", - "user7": "", - "user8": "", - "user9": "", "type": "event", "revision": 0, "id": "0b371", - "cue": "SF1.03" + "cue": "SF1.03", + "custom": {} }, { "title": "Switzerland", @@ -97,20 +70,11 @@ "isPublic": true, "skip": false, "colour": "", - "user0": "", - "user1": "", - "user2": "", - "user3": "", - "user4": "", - "user5": "", - "user6": "", - "user7": "", - "user8": "", - "user9": "", "type": "event", "revision": 0, "id": "3cd28", - "cue": "SF1.04" + "cue": "SF1.04", + "custom": {} }, { "title": "Slovenia", @@ -125,20 +89,11 @@ "isPublic": true, "skip": false, "colour": "", - "user0": "", - "user1": "", - "user2": "", - "user3": "", - "user4": "", - "user5": "", - "user6": "", - "user7": "", - "user8": "", - "user9": "", "type": "event", "revision": 0, "id": "e457f", - "cue": "SF1.05" + "cue": "SF1.05", + "custom": {} }, { "title": "Lunch break", @@ -158,20 +113,11 @@ "isPublic": true, "skip": false, "colour": "", - "user0": "", - "user1": "", - "user2": "", - "user3": "", - "user4": "", - "user5": "", - "user6": "", - "user7": "", - "user8": "", - "user9": "", "type": "event", "revision": 0, "id": "1c420", - "cue": "SF1.06" + "cue": "SF1.06", + "custom": {} }, { "title": "Bulgaria", @@ -186,20 +132,11 @@ "isPublic": true, "skip": false, "colour": "", - "user0": "", - "user1": "", - "user2": "", - "user3": "", - "user4": "", - "user5": "", - "user6": "", - "user7": "", - "user8": "", - "user9": "", "type": "event", "revision": 0, "id": "b7737", - "cue": "SF1.07" + "cue": "SF1.07", + "custom": {} }, { "title": "Netherlands", @@ -214,20 +151,11 @@ "isPublic": true, "skip": false, "colour": "", - "user0": "", - "user1": "", - "user2": "", - "user3": "", - "user4": "", - "user5": "", - "user6": "", - "user7": "", - "user8": "", - "user9": "", "type": "event", "revision": 0, "id": "d3a80", - "cue": "SF1.08" + "cue": "SF1.08", + "custom": {} }, { "title": "Moldova", @@ -242,20 +170,11 @@ "isPublic": true, "skip": false, "colour": "", - "user0": "", - "user1": "", - "user2": "", - "user3": "", - "user4": "", - "user5": "", - "user6": "", - "user7": "", - "user8": "", - "user9": "", "type": "event", "revision": 0, "id": "8276c", - "cue": "SF1.09" + "cue": "SF1.09", + "custom": {} }, { "title": "Portugal", @@ -270,20 +189,11 @@ "isPublic": true, "skip": false, "colour": "", - "user0": "", - "user1": "", - "user2": "", - "user3": "", - "user4": "", - "user5": "", - "user6": "", - "user7": "", - "user8": "", - "user9": "", "type": "event", "revision": 0, "id": "2340b", - "cue": "SF1.10" + "cue": "SF1.10", + "custom": {} }, { "title": "Afternoon break", @@ -303,20 +213,11 @@ "isPublic": true, "skip": false, "colour": "", - "user0": "", - "user1": "", - "user2": "", - "user3": "", - "user4": "", - "user5": "", - "user6": "", - "user7": "", - "user8": "", - "user9": "", "type": "event", "revision": 0, "id": "503c4", - "cue": "SF1.11" + "cue": "SF1.11", + "custom": {} }, { "title": "Denmark", @@ -331,20 +232,11 @@ "isPublic": true, "skip": false, "colour": "", - "user0": "", - "user1": "", - "user2": "", - "user3": "", - "user4": "", - "user5": "", - "user6": "", - "user7": "", - "user8": "", - "user9": "", "type": "event", "revision": 0, "id": "5e965", - "cue": "SF1.12" + "cue": "SF1.12", + "custom": {} }, { "title": "Austria", @@ -359,20 +251,11 @@ "isPublic": true, "skip": false, "colour": "", - "user0": "", - "user1": "", - "user2": "", - "user3": "", - "user4": "", - "user5": "", - "user6": "", - "user7": "", - "user8": "", - "user9": "", "type": "event", "revision": 0, "id": "bab4a", - "cue": "SF1.13" + "cue": "SF1.13", + "custom": {} }, { "title": "Greece", @@ -387,20 +270,11 @@ "isPublic": true, "skip": false, "colour": "", - "user0": "", - "user1": "", - "user2": "", - "user3": "", - "user4": "", - "user5": "", - "user6": "", - "user7": "", - "user8": "", - "user9": "", "type": "event", "revision": 0, "id": "d3eb1", - "cue": "SF1.14" + "cue": "SF1.14", + "custom": {} } ], "project": { @@ -436,18 +310,6 @@ "pathAndParams": "lower?bg=ff2&text=f00&size=0.6&transition=5" } ], - "userFields": { - "user0": "user0", - "user1": "user1", - "user2": "user2", - "user3": "user3", - "user4": "user4", - "user5": "user5", - "user6": "user6", - "user7": "user7", - "user8": "user8", - "user9": "user9" - }, "osc": { "portIn": 8888, "portOut": 9999, diff --git a/packages/types/src/definitions/DataModel.type.ts b/packages/types/src/definitions/DataModel.type.ts index b63879d75..7a707fbce 100644 --- a/packages/types/src/definitions/DataModel.type.ts +++ b/packages/types/src/definitions/DataModel.type.ts @@ -3,7 +3,6 @@ import { ProjectData } from './core/ProjectData.type.js'; import { OntimeRundown } from './core/Rundown.type.js'; import { OSCSettings } from './core/OscSettings.type.js'; import { Settings } from './core/Settings.type.js'; -import { UserFields } from './core/UserFields.type.js'; import { ViewSettings } from './core/Views.type.js'; import { CustomFields, HttpSettings } from '../index.js'; @@ -13,7 +12,6 @@ export type DatabaseModel = { settings: Settings; viewSettings: ViewSettings; aliases: Alias[]; - userFields: UserFields; customFields: CustomFields; osc: OSCSettings; http: HttpSettings; diff --git a/packages/types/src/definitions/core/OntimeEvent.type.ts b/packages/types/src/definitions/core/OntimeEvent.type.ts index 2579767e0..0eb9293d8 100644 --- a/packages/types/src/definitions/core/OntimeEvent.type.ts +++ b/packages/types/src/definitions/core/OntimeEvent.type.ts @@ -39,16 +39,6 @@ export type OntimeEvent = OntimeBaseEvent & { isPublic: boolean; skip: boolean; colour: string; - user0: string; - user1: string; - user2: string; - user3: string; - user4: string; - user5: string; - user6: string; - user7: string; - user8: string; - user9: string; revision: number; delay?: number; // calculated at runtime timeWarning: number; diff --git a/packages/types/src/definitions/core/UserFields.type.ts b/packages/types/src/definitions/core/UserFields.type.ts deleted file mode 100644 index c8962e8be..000000000 --- a/packages/types/src/definitions/core/UserFields.type.ts +++ /dev/null @@ -1,12 +0,0 @@ -export type UserFields = { - user0: string; - user1: string; - user2: string; - user3: string; - user4: string; - user5: string; - user6: string; - user7: string; - user8: string; - user9: string; -}; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 6f736180c..acef71797 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -27,9 +27,6 @@ export type { TimeFormat } from './definitions/core/TimeFormat.type.js'; // ---> Aliases export type { Alias } from './definitions/core/Alias.type.js'; -// ---> User Fields -export type { UserFields } from './definitions/core/UserFields.type.js'; - // ---> Custom Fields export type { CustomFields, diff --git a/packages/utils/index.ts b/packages/utils/index.ts index fbbc5b039..5535b3939 100644 --- a/packages/utils/index.ts +++ b/packages/utils/index.ts @@ -55,6 +55,7 @@ export { deepmerge } from './src/externals/deepmerge.js'; export { deleteAtIndex, insertAtIndex, reorderArray, sortArrayByProperty } from './src/array-utils/arrayUtils.js'; // generic utilities +export { unpackError } from './src/generic/generic.js'; export { isNumeric } from './src/types/types.js'; // model validation @@ -62,10 +63,11 @@ export { validateEndAction, validateTimerType } from './src/validate-events/vali // feature business logic -// feature business logic - excel import +// feature business logic - spreadsheet import export { - type ExcelImportMap, - type ExcelImportOptions, - defaultExcelImportMap, - isExcelImportMap, -} from './src/feature/excel-import/excelImport.js'; + type ImportCustom, + type ImportMap, + type ImportOptions, + defaultImportMap, + isImportMap, +} from './src/feature/spreadsheet-import/spreadsheetImport.js'; diff --git a/packages/utils/src/feature/excel-import/__tests__/excelImport.test.ts b/packages/utils/src/feature/excel-import/__tests__/excelImport.test.ts deleted file mode 100644 index 94c755dc1..000000000 --- a/packages/utils/src/feature/excel-import/__tests__/excelImport.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { isExcelImportMap } from '../excelImport'; - -describe('isExcelImportMap', () => { - test('migrate v2 map', () => { - const v2ImportMap = { - worksheet: 'event schedule', - projectName: 'project name', - projectDescription: 'project description', - publicUrl: 'public url', - publicInfo: 'public info', - backstageUrl: 'backstage url', - backstageInfo: 'backstage info', - timeStart: 'time start', - timeEnd: 'time end', - duration: 'duration', - cue: 'cue', - title: 'title', - presenter: 'presenter', - subtitle: 'subtitle', - isPublic: 'public', - skip: 'skip', - note: 'notes', - colour: 'colour', - endAction: 'end action', - timerType: 'timer type', - user0: 'header', - user1: 'user1', - user2: 'user2', - user3: 'user3', - user4: 'user4', - user5: 'user5', - user6: 'user6', - user7: 'user7', - user8: 'user8', - user9: 'user9', - timeWarning: 'warning time', - timeDanger: 'danger time', - }; - - expect(isExcelImportMap(v2ImportMap)).toBe(true); - }); -}); diff --git a/packages/utils/src/feature/spreadsheet-import/__tests__/spreadsheetImport.test.ts b/packages/utils/src/feature/spreadsheet-import/__tests__/spreadsheetImport.test.ts new file mode 100644 index 000000000..518b8e63d --- /dev/null +++ b/packages/utils/src/feature/spreadsheet-import/__tests__/spreadsheetImport.test.ts @@ -0,0 +1,54 @@ +import { isImportMap } from '../spreadsheetImport'; + +describe('isImportMap()', () => { + it('validates a v3 default import map', () => { + const v3ImportMap = { + worksheet: 'event schedule', + timeStart: 'time start', + timeEnd: 'time end', + duration: 'duration', + cue: 'cue', + title: 'title', + presenter: 'presenter', + subtitle: 'subtitle', + isPublic: 'public', + skip: 'skip', + note: 'notes', + colour: 'colour', + endAction: 'end action', + timerType: 'timer type', + timeWarning: 'warning time', + timeDanger: 'danger time', + custom: {}, + }; + + expect(isImportMap(v3ImportMap)).toBe(true); + }); + + it('handles custom properties', () => { + const v3ImportMap = { + worksheet: 'event schedule', + timeStart: 'time start', + timeEnd: 'time end', + duration: 'duration', + cue: 'cue', + title: 'title', + presenter: 'presenter', + subtitle: 'subtitle', + isPublic: 'public', + skip: 'skip', + note: 'notes', + colour: 'colour', + endAction: 'end action', + timerType: 'timer type', + timeWarning: 'warning time', + timeDanger: 'danger time', + custom: { + userDefined: 'userDefined', + anotherOne: 'anotherOne', + }, + }; + + expect(isImportMap(v3ImportMap)).toBe(true); + }); +}); diff --git a/packages/utils/src/feature/excel-import/excelImport.ts b/packages/utils/src/feature/spreadsheet-import/spreadsheetImport.ts similarity index 52% rename from packages/utils/src/feature/excel-import/excelImport.ts rename to packages/utils/src/feature/spreadsheet-import/spreadsheetImport.ts index 316a43f4f..82205dffc 100644 --- a/packages/utils/src/feature/excel-import/excelImport.ts +++ b/packages/utils/src/feature/spreadsheet-import/spreadsheetImport.ts @@ -1,7 +1,9 @@ -export type ExcelImportOptions = keyof typeof defaultExcelImportMap; -export type ExcelImportMap = typeof defaultExcelImportMap; +export type ImportOptions = keyof typeof defaultImportMap | 'custom'; +export type ImportCustom = Record; +export type ImportMap = typeof defaultImportMap & { custom: ImportCustom }; -export const defaultExcelImportMap = { +// Record of ontime name and import name +export const defaultImportMap = { worksheet: 'event schedule', timeStart: 'time start', timeEnd: 'time end', @@ -16,25 +18,20 @@ export const defaultExcelImportMap = { colour: 'colour', endAction: 'end action', timerType: 'timer type', - user0: 'user0', - user1: 'user1', - user2: 'user2', - user3: 'user3', - user4: 'user4', - user5: 'user5', - user6: 'user6', - user7: 'user7', - user8: 'user8', - user9: 'user9', timeWarning: 'warning time', timeDanger: 'danger time', + custom: {}, }; -export function isExcelImportMap(obj: unknown): obj is ExcelImportMap { +/** + * Validates whether an object is an Import Map + * @param obj + */ +export function isImportMap(obj: unknown): obj is ImportMap { if (typeof obj !== 'object' || obj === null) { return false; } - const keys = Object.keys(defaultExcelImportMap); + const keys = Object.keys(defaultImportMap); return keys.every((key) => Object.hasOwn(obj, key)); } diff --git a/packages/utils/src/generic/generic.ts b/packages/utils/src/generic/generic.ts new file mode 100644 index 000000000..ae378d4ba --- /dev/null +++ b/packages/utils/src/generic/generic.ts @@ -0,0 +1,6 @@ +export function unpackError(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + return String(error); +}