diff --git a/apps/client/src/common/api/apiConstants.ts b/apps/client/src/common/api/apiConstants.ts index 45d29be91..ccd7f5de1 100644 --- a/apps/client/src/common/api/apiConstants.ts +++ b/apps/client/src/common/api/apiConstants.ts @@ -10,6 +10,7 @@ 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']; const location = window.location; diff --git a/apps/client/src/common/api/ontimeApi.ts b/apps/client/src/common/api/ontimeApi.ts index 29e6e2385..bb49c0119 100644 --- a/apps/client/src/common/api/ontimeApi.ts +++ b/apps/client/src/common/api/ontimeApi.ts @@ -1,6 +1,10 @@ import axios, { AxiosResponse } from 'axios'; import { Alias, + AuthenticationStatus, + CustomField, + CustomFieldLabel, + CustomFields, DatabaseModel, GetInfo, HttpSettings, @@ -18,7 +22,7 @@ import { ExcelImportMap } from 'ontime-utils'; import { apiRepoLatest } from '../../externals'; import fileDownload from '../utils/fileDownload'; -import { ontimeURL } from './apiConstants'; +import { ontimeURL, projectDataURL } from './apiConstants'; /** * @description HTTP request to retrieve application settings @@ -257,79 +261,65 @@ export async function loadProject(filename: string): Promise { } /** - * @description STEP 1 + * @description HTTP request to initiate the authentication service with google */ -export const uploadSheetClientFile = async (file: File) => { +export const requestConnection = async ( + file: File, + sheetId: string, +): Promise<{ + verification_url: string; + user_code: string; +}> => { const formData = new FormData(); - formData.append('userFile', file); - const res = await axios - .post(`${ontimeURL}/sheet/clientsecret`, formData, { - headers: { - 'Content-Type': 'multipart/form-data', - }, - }) - .then((response) => response.data.id); - return res; -}; + formData.append('client_secret', file); + + const response = await axios.post(`${ontimeURL}/sheet/${sheetId}/connect`, formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); -/** - * @description STEP 1 test - */ -// TODO: do we still need this? -export const getClientSecret = async () => { - const response = await axios.get(`${ontimeURL}/sheet/clientsecret`); return response.data; }; /** - * @description STEP 2 + * @description HTTP request to verify whether we are authenticated with Google Sheet service */ -export const getSheetsAuthUrl = async () => { - const response = await axios.get(`${ontimeURL}/sheet/authentication/url`); +export const verifyAuthenticationStatus = async (): Promise<{ authenticated: AuthenticationStatus }> => { + const response = await axios.get(`${ontimeURL}/sheet/connect`); return response.data; }; /** - * @description STEP 2 test + * @description HTTP request to revoke authentication to google sheet */ -export const getAuthentication = async () => { - const response = await axios.get(`${ontimeURL}/sheet/authentication`); +export const revokeAuthentication = async (): Promise<{ authenticated: AuthenticationStatus }> => { + const response = await axios.post(`${ontimeURL}/sheet/revoke`); return response.data; }; /** - * @description STEP 3 - * @returns worksheetOptions + * @description HTTP request to upload preview the contents of a google sheet as rundown */ -export const postId = async (sheetId: string) => { - const response = await axios.post(`${ontimeURL}/sheet/sheetId`, { sheetId }); +export const previewRundown = async ( + sheetId: string, + options: ExcelImportMap, +): Promise<{ + rundown: OntimeRundown; + userFields: UserFields; +}> => { + const response = await axios.post(`${ontimeURL}/sheet/${sheetId}/read`, { options }); return response.data; }; /** - * @description STEP 4 + * @description HTTP request to upload the rundown to a google sheet */ -export const postWorksheet = async (sheetId: string, worksheet: string) => { - const response = await axios.post(`${ontimeURL}/sheet/worksheet`, { sheetId, worksheet }); +export const uploadRundown = async (sheetId: string, options: ExcelImportMap): Promise => { + const response = await axios.post(`${ontimeURL}/sheet/${sheetId}/write`, { options }); return response.data; }; -/** - * @description STEP 5 - */ -export const postPreviewSheet = async (sheetId: string, options: ExcelImportMap) => { - const response = await axios.post(`${ontimeURL}/sheet-pull`, { sheetId, options }); - return response.data.data; -}; - -/** - * @description STEP 5 - */ -export const postPushSheet = async (sheetId: string, options: ExcelImportMap) => { - const response = await axios.post(`${ontimeURL}/sheet-push`, { sheetId, options }); - return response.data.data; -}; - /** * @description HTTP request to rename a project file */ @@ -374,8 +364,33 @@ export async function createProject( } >, ): Promise { + // TODO: is this URL correct? const url = `${ontimeURL}/project`; const decodedUrl = decodeURIComponent(url); const res = await axios.post(decodedUrl, project); return res.data; } + +export async function getCustomFields(): Promise { + const res = await axios.get(`${projectDataURL}/custom-field`); + return res.data; +} + +export async function postCustomField(newField: CustomField): Promise { + const res = await axios.post(`${projectDataURL}/custom-field`, { + ...newField, + }); + return res.data; +} + +export async function editCustomField(label: CustomFieldLabel, newField: CustomField): Promise { + const res = await axios.put(`${projectDataURL}/custom-field/${label}`, { + ...newField, + }); + return res.data; +} + +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/common/components/copy-tag/CopyTag.tsx b/apps/client/src/common/components/copy-tag/CopyTag.tsx index 82aa6171c..1464d1aec 100644 --- a/apps/client/src/common/components/copy-tag/CopyTag.tsx +++ b/apps/client/src/common/components/copy-tag/CopyTag.tsx @@ -10,20 +10,28 @@ interface CopyTagProps { label: string; className?: string; size?: Size; + disabled?: boolean; } export default function CopyTag(props: PropsWithChildren) { - const { label, className, size = 'xs', children } = props; + const { label, className, size = 'xs', disabled, children } = props; const handleClick = () => copyToClipboard(children as string); return ( - - } variant='ontime-filled' tabIndex={-1} onClick={handleClick} /> + } + variant='ontime-filled' + tabIndex={-1} + onClick={handleClick} + isDisabled={disabled} + /> ); diff --git a/apps/client/src/common/components/input/colour-input/Swatch.tsx b/apps/client/src/common/components/input/colour-input/Swatch.tsx index f04272d59..67c6fe34e 100644 --- a/apps/client/src/common/components/input/colour-input/Swatch.tsx +++ b/apps/client/src/common/components/input/colour-input/Swatch.tsx @@ -6,21 +6,24 @@ import style from './SwatchSelect.module.scss'; interface SwatchProps { color: string; - onClick: (color: string) => void; + onClick?: (color: string) => void; isSelected?: boolean; } export default function Swatch(props: SwatchProps) { const { color, isSelected, onClick } = props; - const classes = cx([style.swatch, isSelected ? style.selected : null]); + const handleClick = () => { + onClick?.(color); + }; + const classes = cx([style.swatch, isSelected ? style.selected : null, onClick ? style.selectable : null]); if (!color) { return ( -
onClick('')}> +
); } - return
onClick(color)} />; + return
; } diff --git a/apps/client/src/common/components/input/colour-input/SwatchSelect.module.scss b/apps/client/src/common/components/input/colour-input/SwatchSelect.module.scss index 20f682d7d..45aa602e9 100644 --- a/apps/client/src/common/components/input/colour-input/SwatchSelect.module.scss +++ b/apps/client/src/common/components/input/colour-input/SwatchSelect.module.scss @@ -5,7 +5,6 @@ } .swatch { - cursor: pointer; width: 2rem; height: 2rem; aspect-ratio: 1; @@ -15,6 +14,10 @@ &.selected { border: 2px solid $blue-500; } + + &.selectable { + cursor: pointer; + } } .center { diff --git a/apps/client/src/common/hooks-query/useCustomFields.ts b/apps/client/src/common/hooks-query/useCustomFields.ts new file mode 100644 index 000000000..f74d14896 --- /dev/null +++ b/apps/client/src/common/hooks-query/useCustomFields.ts @@ -0,0 +1,22 @@ +import { useQuery } from '@tanstack/react-query'; +import { CustomFields } from 'ontime-types'; + +import { queryRefetchInterval } from '../../ontimeConfig'; +import { CUSTOM_FIELDS } from '../api/apiConstants'; +import { getCustomFields } from '../api/ontimeApi'; + +const placeholder: CustomFields = {}; + +export default function useCustomFields() { + const { data, status, isFetching, isError, refetch } = useQuery({ + queryKey: CUSTOM_FIELDS, + queryFn: getCustomFields, + placeholderData: placeholder, + retry: 5, + retryDelay: (attempt) => attempt * 2500, + refetchInterval: queryRefetchInterval, + networkMode: 'always', + }); + + return { data: data ?? placeholder, status, isFetching, isError, refetch }; +} diff --git a/apps/client/src/common/utils/__tests__/regex.test.ts b/apps/client/src/common/utils/__tests__/regex.test.ts index ebfa24983..7e34a7fad 100644 --- a/apps/client/src/common/utils/__tests__/regex.test.ts +++ b/apps/client/src/common/utils/__tests__/regex.test.ts @@ -1,4 +1,4 @@ -import { isIPAddress, isOnlyNumbers, startsWithHttp, startsWithSlash } from '../regex'; +import { isAlphanumeric, isIPAddress, isNotEmpty, isOnlyNumbers, startsWithHttp, startsWithSlash } from '../regex'; describe('simple tests for regex', () => { test('isOnlyNumbers', () => { @@ -48,4 +48,28 @@ describe('simple tests for regex', () => { expect(startsWithSlash.test(t)).toBe(false); }); }); + + test('isAlphanumeric', () => { + const right = ['dsafdsafa9f9sdafdsSADFHASDF', '1231', '1', 'a', 'asdas1asdas', '11as', '1']; + const wrong = ['with space', 'with @', '#']; + + right.forEach((t) => { + expect(isAlphanumeric.test(t)).toBe(true); + }); + wrong.forEach((t) => { + expect(isAlphanumeric.test(t)).toBe(false); + }); + }); + + test('isNotEmpty', () => { + const right = ['notempty']; + const wrong = ['', ' ']; + + right.forEach((t) => { + expect(isNotEmpty.test(t)).toBe(true); + }); + wrong.forEach((t) => { + expect(isNotEmpty.test(t)).toBe(false); + }); + }); }); diff --git a/apps/client/src/common/utils/eventsManager.ts b/apps/client/src/common/utils/eventsManager.ts index 4d8228889..eac46c0dc 100644 --- a/apps/client/src/common/utils/eventsManager.ts +++ b/apps/client/src/common/utils/eventsManager.ts @@ -8,7 +8,19 @@ import { OntimeEvent, SupportedEvent } from 'ontime-types'; */ type ClonedEvent = Omit< OntimeEvent, - 'id' | 'cue' | 'user0' | 'user1' | 'user2' | 'user3' | 'user4' | 'user5' | 'user6' | 'user7' | 'user8' | 'user9' + | 'id' + | 'cue' + | 'user0' + | 'user1' + | 'user2' + | 'user3' + | 'user4' + | 'user5' + | 'user6' + | 'user7' + | 'user8' + | 'user9' + | 'custom' >; export const cloneEvent = (event: OntimeEvent, after?: string): ClonedEvent => { return { diff --git a/apps/client/src/common/utils/regex.ts b/apps/client/src/common/utils/regex.ts index 9f9d0f2bc..fb78f477e 100644 --- a/apps/client/src/common/utils/regex.ts +++ b/apps/client/src/common/utils/regex.ts @@ -7,3 +7,5 @@ export const isOnlyNumbers = /^\d+$/; export const isIPAddress = /^((25[0-5]|(2[0-4]|1\d|[1-9]|)\d)\.?\b){4}$/; export const startsWithHttp = /^http:\/\//; export const startsWithSlash = /^\//; +export const isAlphanumeric = /^[a-z0-9]+$/i; +export const isNotEmpty = /\S/; diff --git a/apps/client/src/features/app-settings/panel/integrations-panel/OscIntegrations.tsx b/apps/client/src/features/app-settings/panel/integrations-panel/OscIntegrations.tsx index b833b163a..a37687516 100644 --- a/apps/client/src/features/app-settings/panel/integrations-panel/OscIntegrations.tsx +++ b/apps/client/src/features/app-settings/panel/integrations-panel/OscIntegrations.tsx @@ -99,7 +99,6 @@ export default function OscIntegrations() { - OSC Settings {errors?.root && {errors.root.message}} diff --git a/apps/client/src/features/app-settings/panel/project-settings-panel/CustomFieldEntry.tsx b/apps/client/src/features/app-settings/panel/project-settings-panel/CustomFieldEntry.tsx new file mode 100644 index 000000000..63e076439 --- /dev/null +++ b/apps/client/src/features/app-settings/panel/project-settings-panel/CustomFieldEntry.tsx @@ -0,0 +1,72 @@ +import { useState } from 'react'; +import { IconButton } from '@chakra-ui/react'; +import { IoPencil } from '@react-icons/all-files/io5/IoPencil'; +import { IoTrash } from '@react-icons/all-files/io5/IoTrash'; +import { CustomField, CustomFieldLabel } from 'ontime-types'; + +import Swatch from '../../../../common/components/input/colour-input/Swatch'; + +import CustomFieldForm from './CustomFieldForm'; + +import style from './ProjectSettingsPanel.module.scss'; + +interface CustomFieldEntryProps { + colour: string; + label: string; + onEdit: (label: CustomFieldLabel, patch: CustomField) => Promise; + onDelete: (label: CustomFieldLabel) => Promise; +} + +export default function CustomFieldEntry(props: CustomFieldEntryProps) { + const { colour, label, onEdit, onDelete } = props; + + const [isEditing, setIsEditing] = useState(false); + + const handleEdit = async (patch: CustomField) => { + const oldLabel = label; + await onEdit(oldLabel, patch); + setIsEditing(false); + }; + + if (isEditing) { + return ( + + + setIsEditing(false)} + onSubmit={handleEdit} + initialColour={colour} + initialLabel={label} + /> + + + ); + } + + return ( + + + + + {label} + + } + aria-label='Edit entry' + onClick={() => setIsEditing(true)} + /> + } + aria-label='Delete entry' + onClick={() => onDelete(label)} + /> + + + ); +} diff --git a/apps/client/src/features/app-settings/panel/project-settings-panel/CustomFieldForm.tsx b/apps/client/src/features/app-settings/panel/project-settings-panel/CustomFieldForm.tsx new file mode 100644 index 000000000..f519d925f --- /dev/null +++ b/apps/client/src/features/app-settings/panel/project-settings-panel/CustomFieldForm.tsx @@ -0,0 +1,99 @@ +import { useEffect } from 'react'; +import { useForm } from 'react-hook-form'; +import { Button, Input } from '@chakra-ui/react'; +import { CustomField } from 'ontime-types'; +import { isAlphanumeric } from 'ontime-utils'; + +import { maybeAxiosError } from '../../../../common/api/apiUtils'; +import SwatchSelect from '../../../../common/components/input/colour-input/SwatchSelect'; +import * as Panel from '../PanelUtils'; + +import style from './ProjectSettingsPanel.module.scss'; + +interface CustomFieldsFormProps { + onSubmit: (field: CustomField) => Promise; + onCancel: () => void; + initialColour?: string; + initialLabel?: string; +} + +export default function CustomFieldForm(props: CustomFieldsFormProps) { + const { onSubmit, onCancel, initialColour, initialLabel } = props; + + const { + handleSubmit, + register, + setFocus, + setError, + setValue, + getValues, + formState: { errors, isSubmitting, isValid, isDirty }, + } = useForm({ + defaultValues: { label: initialLabel || '', colour: initialColour || '' }, + resetOptions: { + keepDirtyValues: true, + }, + }); + + const setupSubmit = async (values: { label: string; colour: string }) => { + const { label, colour } = values; + const newField: CustomField = { + type: 'string', // type is not user definable yet + colour, + label, + }; + try { + await onSubmit(newField); + } catch (error) { + setError('root', { type: 'custom', message: maybeAxiosError(error) }); + } + }; + + // give initial focus to the label + useEffect(() => { + setFocus('label'); + }, [setFocus]); + + const handleSelectColour = (colour: string) => { + setValue('colour', colour, { shouldDirty: true }); + }; + + const colour = getValues('colour'); + const canSubmit = isDirty && isValid; + + return ( +
+
+ Label + {errors.label && {errors.label.message}} + { + if (value.trim().length === 0) return 'Required field'; + if (!isAlphanumeric(value)) return 'Only alphanumeric characters are allowed'; + return true; + }, + })} + size='sm' + variant='ontime-filled' + autoComplete='off' + /> +
+ +
+ Colour + handleSelectColour(value)} /> +
+ {errors.root && {errors.root.message}} +
+ + +
+
+ ); +} diff --git a/apps/client/src/features/app-settings/panel/project-settings-panel/ProjectSettingsPanel.module.scss b/apps/client/src/features/app-settings/panel/project-settings-panel/ProjectSettingsPanel.module.scss index be4a893d6..5ba2dd0a5 100644 --- a/apps/client/src/features/app-settings/panel/project-settings-panel/ProjectSettingsPanel.module.scss +++ b/apps/client/src/features/app-settings/panel/project-settings-panel/ProjectSettingsPanel.module.scss @@ -1,8 +1,22 @@ .fullWidth { - width: 100%; + width: 100%; } .actions { - display: flex; - gap: 0.5rem; + display: flex; + gap: 0.5rem; +} + +.fieldForm { + padding: 1rem; + background-color: $gray-1350; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.buttonRow { + display: flex; + justify-content: flex-end; + gap: 1rem; } 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 56703a084..76c6c9aac 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 @@ -1,74 +1,94 @@ -import { Alert, AlertDescription, AlertIcon, IconButton } from '@chakra-ui/react'; -import { IoPencil } from '@react-icons/all-files/io5/IoPencil'; -import { IoTrash } from '@react-icons/all-files/io5/IoTrash'; +import { useState } from 'react'; +import { Alert, AlertDescription, AlertIcon, Button } from '@chakra-ui/react'; +import { IoAdd } from '@react-icons/all-files/io5/IoAdd'; +import { CustomField, CustomFieldLabel } from 'ontime-types'; +import { deleteCustomField, editCustomField, postCustomField } from '../../../../common/api/ontimeApi'; import ExternalLink from '../../../../common/components/external-link/ExternalLink'; +import useCustomFields from '../../../../common/hooks-query/useCustomFields'; import * as Panel from '../PanelUtils'; -import style from './ProjectSettingsPanel.module.scss'; - -const demoCustomFields = { - Apple: { value: 'Fruit' }, - Dog: { value: 'Animal' }, - Sun: { value: 'Star' }, - Car: { value: 'Vehicle' }, - Tree: { value: 'Plant' }, - Bird: { value: 'Creature' }, - Book: { value: 'Reading' }, - Chair: { value: 'Furniture' }, - Music: { value: 'Melody' }, - Ocean: { value: 'Sea' }, -}; +import CustomFieldEntry from './CustomFieldEntry'; +import CustomFieldForm from './CustomFieldForm'; const userFieldsDocsUrl = 'https://ontime.gitbook.io/v2/features/user-fields'; export default function ProjectSettingsPanel() { + const { data, refetch } = useCustomFields(); + const [isAdding, setIsAdding] = useState(false); + + const handleInitiateCreate = () => { + setIsAdding(true); + }; + + const handleCancel = () => { + setIsAdding(false); + }; + + const handleCreate = async (customField: CustomField) => { + await postCustomField(customField); + refetch(); + setIsAdding(false); + }; + + const handleEditField = async (label: CustomFieldLabel, customField: CustomField) => { + await editCustomField(label, customField); + refetch(); + }; + + const handleDelete = async (label: string) => { + try { + await deleteCustomField(label); + refetch(); + } catch (_error) { + /** we do not handle errors here */ + } + }; + return ( <> Project Settings - Custom fields -
+ + Custom fields + + + Custom fields allow for additional information to be added to an event (eg. light, sound, camera).{' '}
- This data is not used by Ontime.
+
+ This data is not used by Ontime. See the docs
-
+
+ {isAdding && } + Colour Name - {Object.entries(demoCustomFields).map(([key, { value }]) => ( - - {value} - - } - aria-label='Edit entry' - /> - } - aria-label='Delete entry' - /> - - - ))} + {Object.entries(data).map(([key, { colour, label }]) => { + return ( + + ); + })} 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 5fb2214ec..dddab0275 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 @@ -1,9 +1,10 @@ -import { useRef } from 'react'; -import { Button, Input, Select } from '@chakra-ui/react'; +import { ChangeEvent, useEffect, useState } from 'react'; +import { Button, Input } from '@chakra-ui/react'; import { IoCheckmark } from '@react-icons/all-files/io5/IoCheckmark'; -import { IoCloudDownloadOutline } from '@react-icons/all-files/io5/IoCloudDownloadOutline'; import { IoShieldCheckmarkOutline } from '@react-icons/all-files/io5/IoShieldCheckmarkOutline'; +import CopyTag from '../../../../common/components/copy-tag/CopyTag'; +import { openLink } from '../../../../common/utils/linkUtils'; import * as Panel from '../PanelUtils'; import useGoogleSheet from './useGoogleSheet'; @@ -12,140 +13,177 @@ import { useSheetStore } from './useSheetStore'; import style from './SourcesPanel.module.scss'; interface GSheetSetupProps { - cancel: () => void; + onCancel: () => void; } -export default function GSheetSetup({ cancel }: GSheetSetupProps) { - const { handleClientSecret, handleAuthenticate, handleConnect } = useGoogleSheet(); - - const sheetIdInputRef = useRef(null); - - const stepData = useSheetStore((state) => state.stepData); - const reset = useSheetStore((state) => state.reset); +export default function GSheetSetup({ onCancel }: GSheetSetupProps) { + const { revoke, connect, verifyAuth } = useGoogleSheet(); + const [file, setFile] = useState(null); + const [authKey, setAuthKey] = useState(null); + const [loading, setLoading] = useState<'' | 'cancel' | 'connect' | 'authenticate'>(''); + const [authLink, setAuthLink] = useState(''); const sheetId = useSheetStore((state) => state.sheetId); - const worksheetOptions = useSheetStore((state) => state.worksheetOptions) ?? []; - - const setWorksheet = useSheetStore((state) => state.setWorksheet); const setSheetId = useSheetStore((state) => state.setSheetId); - const worksheetIdInputRef = useRef(null); + const authenticationStatus = useSheetStore((state) => state.authenticationStatus); + const setAuthenticationStatus = useSheetStore((state) => state.setAuthenticationStatus); + + /** Check if we are authenticated */ + const getAuthStatus = async () => { + const result = await verifyAuth(); + if (result) { + setAuthenticationStatus(result.authenticated); + } + }; + + /** check if the current session has been authenticated */ + useEffect(() => { + getAuthStatus(); + }, []); + + const handleCancelFlow = () => { + revoke(); + onCancel(); + }; // user cancels the flow - const onCancel = () => { - reset(); - cancel(); + const handleRevoke = async () => { + setLoading('cancel'); + await revoke(); + await getAuthStatus(); + setLoading(''); }; - // connect to the accoutn with the given sheet ID - const connectToId = () => { - const sheetId = sheetIdInputRef.current?.value; + /** + * Gets file from input + * @param event + */ + const handleClientSecret = async (event: ChangeEvent) => { + if (!event.target.files?.length) { + return; + } + setFile(event.target.files[0]); + }; + + /** + * Requests connection to google auth + */ + const handleConnect = async () => { + if (!file) return; if (!sheetId) return; - handleConnect(sheetId); + setLoading('connect'); + const result = await connect(file, sheetId); + if (result) { + setAuthLink(result.verification_url); + setAuthKey(result.user_code); + } + setLoading(''); }; - // adds the user input sheet ID to the store - const addSheetId = () => { - const sheetId = sheetIdInputRef.current?.value; - console.log('adding', sheetId); - if (!sheetId) return; - setSheetId(sheetId); + /** + * Open google auth + */ + const handleAuthenticate = async () => { + setLoading('authenticate'); + + // open link and schedule a check for when the user focuses again + openLink(authLink); + window.addEventListener( + 'focus', + async () => { + getAuthStatus(); + setLoading(''); + }, + { once: true }, + ); }; - // adds the selected worksheet to the store - const addWorksheetSheetId = () => { - const worksheetId = worksheetIdInputRef.current?.value; - if (!worksheetId) return; - setWorksheet(worksheetId); - }; - - const canAuthenticate = stepData.authenticate.available; - const canConnect = stepData.authenticate.available && sheetId; + const canConnect = file && sheetId; + const canAuthenticate = Boolean(authKey) && Boolean(authLink); + const isLoading = Boolean(loading); + const isAuthenticated = authenticationStatus === 'authenticated'; return ( Sync with Google Sheet (experimental) - - -
-
- -
- -
- {stepData.clientSecret.error} -
+ + ) : ( + <> + + Upload Client Secret provided by Google + {undefined} + + - - {stepData.sheetId.error} -
-
+ + Enter ID of sheet to synchronise + {undefined} setSheetId(event.target.value)} + isDisabled={isLoading || canAuthenticate} /> -
- -
-
+ - -
-
- -
- -
- {stepData.worksheet.error} - {stepData.pullPush.error} -
+ {!canAuthenticate ? ( + +
+ +
+
+ ) : ( + +
+ + {authKey ? authKey : 'Upload files to generate Auth Key'} + + +
+
+ )} + + )}
); } 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 index a59066855..1b5ee8026 100644 --- a/apps/client/src/features/app-settings/panel/sources-panel/ImportMap.tsx +++ b/apps/client/src/features/app-settings/panel/sources-panel/ImportMap.tsx @@ -1,3 +1,4 @@ +import { useState } from 'react'; import { Button } from '@chakra-ui/react'; import ExcelFileOptions from '../../../modals/upload-modal/upload-options/ExcelFileOptions'; @@ -9,34 +10,53 @@ import { useSheetStore } from './useSheetStore'; import style from './SourcesPanel.module.scss'; export default function ImportMap() { - const { handleImportPreview, handleExport } = useGoogleSheet(); + const { importRundownPreview, exportRundown } = useGoogleSheet(); - const sheetId = useSheetStore((state) => state.sheetId); - const worksheetId = useSheetStore((state) => state.worksheet); 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 exportRundown = () => { - if (!worksheetId || !sheetId) return; - handleExport(sheetId, worksheetId, importOptions); + const [loading, setLoading] = useState<'' | 'export' | 'import'>(''); + + const handleExport = async () => { + if (!sheetId) return; + setLoading('export'); + await exportRundown(sheetId, importOptions); + setLoading(''); }; - const importPreviewRundown = () => { - if (!worksheetId || !sheetId) return; - handleImportPreview(sheetId, worksheetId, importOptions); + 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 04bb09c39..e6bdd5f62 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 @@ -14,11 +14,11 @@ interface ImportReviewProps { } export default function ImportReview({ rundown, userFields }: ImportReviewProps) { - const { handleImport } = useGoogleSheet(); + const { importRundown } = useGoogleSheet(); const resetPreview = useSheetStore((state) => state.resetPreview); const applyImport = () => { - handleImport(rundown, userFields); + importRundown(rundown, userFields); }; return ( 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 e7e4d67bd..5aa0ed68f 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 @@ -14,6 +14,7 @@ .buttonRow { display: flex; gap: 1rem; + justify-content: end; } .inputContainer { 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 ae1592e6d..b7ba61e84 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 @@ -16,14 +16,16 @@ import style from './SourcesPanel.module.scss'; export default function SourcesPanel() { const [importFlow, setImportFlow] = useState<'none' | 'excel' | 'gsheet'>('none'); - const hasDataSource = useSheetStore((state) => state.stepData.worksheet.available); + const authenticationStatus = useSheetStore((state) => state.authenticationStatus); const rundown = useSheetStore((state) => state.rundown); const userFields = useSheetStore((state) => state.userFields); + + const isAuthenticated = authenticationStatus === 'authenticated'; const hasData = rundown && userFields; const fileInputRef = useRef(null); - const handleFile = () => console.error('not yet implementeed'); + const handleFile = () => console.error('not yet implemented'); const handleUpload = () => { fileInputRef.current?.click(); @@ -73,9 +75,9 @@ export default function SourcesPanel() {
)} - {isGSheetFlow && } + {isGSheetFlow && } {isExcelFlow && Not yet implemented} - {hasDataSource && } + {isAuthenticated && } {hasData && } 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 939c79a70..cf2f15d45 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,111 +1,60 @@ -import { ChangeEvent } from 'react'; import { useQueryClient } from '@tanstack/react-query'; -import { OntimeRundown, UserFields } from 'ontime-types'; +import { AuthenticationStatus, OntimeRundown, UserFields } from 'ontime-types'; import { ExcelImportMap } from 'ontime-utils'; import { RUNDOWN, USERFIELDS } from '../../../../common/api/apiConstants'; import { maybeAxiosError } from '../../../../common/api/apiUtils'; import { - getAuthentication, - getClientSecret, - getSheetsAuthUrl, patchData, - postId, - postPreviewSheet, - postPushSheet, - postWorksheet, - uploadSheetClientFile, + previewRundown, + requestConnection, + revokeAuthentication, + uploadRundown, + verifyAuthenticationStatus, } from '../../../../common/api/ontimeApi'; -import { openLink } from '../../../../common/utils/linkUtils'; import { useSheetStore } from './useSheetStore'; -// TODO: recover useEffect for resuming previous state export default function useGoogleSheet() { const queryClient = useQueryClient(); - // functions push data to store - const setClientSecret = useSheetStore((state) => state.setClientSecret); const patchStepData = useSheetStore((state) => state.patchStepData); - const setSheetId = useSheetStore((state) => state.setSheetId); - const setWorksheetOptions = useSheetStore((state) => state.setWorksheetOptions); const setRundown = useSheetStore((state) => state.setRundown); const setUserFields = useSheetStore((state) => state.setUserFields); - /** receives a client secrets file and passes on to the server */ - const handleClientSecret = async (event: ChangeEvent) => { - if (!event.target.files?.length) { - patchStepData({ - clientSecret: { available: true, error: 'Missing file' }, - authenticate: { available: false, error: '' }, - }); - return; - } - + /** whether the current session has been authenticated */ + const verifyAuth = async (): Promise<{ authenticated: AuthenticationStatus } | void> => { try { - const selectedFile = event.target.files[0]; - await uploadSheetClientFile(selectedFile); - // TODO: why do we need this call? - await getClientSecret(); - setClientSecret(selectedFile); - patchStepData({ - clientSecret: { available: true, error: '' }, - authenticate: { available: true, error: '' }, - }); - } catch (error) { - patchStepData({ - clientSecret: { available: true, error: maybeAxiosError(error) }, - authenticate: { available: false, error: '' }, - }); + return verifyAuthenticationStatus(); + } catch (_error) { + /** we do not handle errors here */ } }; - /** authenticate with the Google Sheets API */ - const handleAuthenticate = async () => { + /** requests connection to a google sheet */ + const connect = async ( + file: File, + sheetId: string, + ): Promise<{ verification_url: string; user_code: string } | void> => { try { - const authLink = await getSheetsAuthUrl(); - - // request window to open link and check auth when user is back - openLink(authLink); - window.addEventListener('focus', async () => await getAuthentication(), { once: true }); - - patchStepData({ - authenticate: { available: true, error: '' }, - sheetId: { available: true, error: '' }, - }); - } catch (error) { - patchStepData({ - authenticate: { available: true, error: maybeAxiosError(error) }, - sheetId: { available: false, error: '' }, - }); + return requestConnection(file, sheetId); + } catch (_error) { + /** we do not handle errors here */ } }; - /** fetches data from a Google Sheet by its ID */ - const handleConnect = async (sheetId: string) => { + const revoke = async (): Promise<{ authenticated: AuthenticationStatus } | void> => { try { - setSheetId(sheetId); - const data = await postId(sheetId); - setWorksheetOptions(data.worksheetOptions); - patchStepData({ worksheet: { available: true, error: '' } }); - } catch (error) { - patchStepData({ - sheetId: { available: true, error: maybeAxiosError(error) }, - worksheet: { available: false, error: '' }, - pullPush: { available: false, error: '' }, - }); - setWorksheetOptions([]); + return revokeAuthentication(); + } catch (_error) { + /** we do not handle errors here */ } }; /** fetches data from a worksheet by its ID */ - const handleImportPreview = async (sheetId: string, worksheet: string, fileOptions: ExcelImportMap) => { + const importRundownPreview = async (sheetId: string, fileOptions: ExcelImportMap) => { try { - // update worksheet data in the server - await postWorksheet(sheetId, worksheet); - - // get data from google - const data = await postPreviewSheet(sheetId, fileOptions); + const data = await previewRundown(sheetId, fileOptions); setRundown(data.rundown); setUserFields(data.userFields); } catch (error) { @@ -114,13 +63,10 @@ export default function useGoogleSheet() { }; /** writes data to a worksheet by its ID */ - const handleExport = async (sheetId: string, worksheet: string, fileOptions: ExcelImportMap) => { + const exportRundown = async (sheetId: string, fileOptions: ExcelImportMap) => { try { - // update worksheet data in the server - await postWorksheet(sheetId, worksheet); - // write data to google - await postPushSheet(sheetId, fileOptions); + await uploadRundown(sheetId, fileOptions); patchStepData({ pullPush: { available: false, error: '' } }); } catch (error) { patchStepData({ pullPush: { available: true, error: maybeAxiosError(error) } }); @@ -128,11 +74,11 @@ export default function useGoogleSheet() { }; /** applies rundown and userfields to current project */ - const handleImport = async (rundown: OntimeRundown, userFields: UserFields) => { + const importRundown = async (rundown: OntimeRundown, userFields: UserFields) => { try { await patchData({ rundown, userFields }); - queryClient.setQueryData(RUNDOWN, rundown); - queryClient.setQueryData(USERFIELDS, userFields); + // we are unable to optimistically set the rundown since we need + // it to be normalised await queryClient.invalidateQueries({ queryKey: [...RUNDOWN, ...USERFIELDS], }); @@ -142,11 +88,12 @@ export default function useGoogleSheet() { }; return { - handleClientSecret, - handleAuthenticate, - handleConnect, - handleImportPreview, - handleImport, - handleExport, + connect, + revoke, + verifyAuth, + + importRundownPreview, + importRundown, + exportRundown, }; } 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 920e0e949..aca250fed 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,32 +1,36 @@ -import { OntimeRundown, UserFields } from 'ontime-types'; +import { AuthenticationStatus, OntimeRundown, UserFields } from 'ontime-types'; import { defaultExcelImportMap, ExcelImportMap } from 'ontime-utils'; import { create } from 'zustand'; // TODO: persist excelFileOptions to localStorage type SheetStore = { - clientSecret: File | null; - rundown: OntimeRundown | null; - userFields: UserFields | null; - sheetId: string | null; - worksheet: string | null; - worksheetOptions: string[] | null; - excelFileOptions: ExcelImportMap; stepData: typeof initialStepData; - setClientSecret: (clientSecret: File | null) => void; - setRundown: (rundown: OntimeRundown | null) => void; - setUserFields: (userFields: UserFields | null) => void; - setSheetId: (sheetId: string) => void; - setWorksheet: (worksheet: string) => void; - setWorksheetOptions: (worksheetOptions: string[] | null) => void; - patchExcelFileOptions: (field: T, value: ExcelImportMap[T]) => void; patchStepData: (patch: Partial) => void; + + sheetId: string | null; + setSheetId: (sheetId: string | null) => void; + + authenticationStatus: AuthenticationStatus; + setAuthenticationStatus: (status: AuthenticationStatus) => void; + + rundown: OntimeRundown | null; + setRundown: (rundown: OntimeRundown | null) => void; + + userFields: UserFields | null; + setUserFields: (userFields: UserFields | null) => void; + + worksheetOptions: string[] | null; + setWorksheetOptions: (worksheetOptions: string[] | null) => void; + + excelFileOptions: ExcelImportMap; + patchExcelFileOptions: (field: T, value: ExcelImportMap[T]) => void; + reset: () => void; resetPreview: () => void; }; const initialStepData = { - clientSecret: { available: true, error: '' }, authenticate: { available: false, error: '' }, sheetId: { available: false, error: '' }, worksheet: { available: false, error: '' }, @@ -34,34 +38,40 @@ const initialStepData = { }; const initialState = { - clientSecret: null, + stepData: initialStepData, + sheetId: null, + authenticationStatus: 'not_authenticated' as AuthenticationStatus, rundown: null, userFields: null, - sheetId: null, - worksheet: null, worksheetOptions: null, excelFileOptions: defaultExcelImportMap, - stepData: initialStepData, }; export const useSheetStore = create((set, get) => ({ ...initialState, - setClientSecret: (clientSecret: File | null) => set({ clientSecret }), + + patchStepData: (patch: Partial) => { + const stepData = get().stepData; + set({ stepData: { ...stepData, ...patch } }); + }, + + setSheetId: (sheetId: string | null) => set({ sheetId }), + + setAuthenticationStatus: (status: AuthenticationStatus) => set({ authenticationStatus: status }), + setRundown: (rundown: OntimeRundown | null) => set({ rundown }), + setUserFields: (userFields: UserFields | null) => set({ userFields }), - setSheetId: (sheetId: string) => set({ sheetId }), - setWorksheet: (worksheet: string) => set({ worksheet }), + setWorksheetOptions: (worksheetOptions: string[] | null) => set({ worksheetOptions }), + patchExcelFileOptions: (field: T, value: ExcelImportMap[T]) => { const excelFileOptions = get().excelFileOptions; if (excelFileOptions[field] !== value) { excelFileOptions[field] = value; } }, - patchStepData: (patch: Partial) => { - const stepData = get().stepData; - set({ stepData: { ...stepData, ...patch } }); - }, + reset: () => set(initialState), resetPreview: () => set({ rundown: null, userFields: null }), })); diff --git a/apps/client/src/features/rundown/event-editor/EventEditor.tsx b/apps/client/src/features/rundown/event-editor/EventEditor.tsx index 7f1fb99c8..9aad611be 100644 --- a/apps/client/src/features/rundown/event-editor/EventEditor.tsx +++ b/apps/client/src/features/rundown/event-editor/EventEditor.tsx @@ -1,14 +1,17 @@ import { useCallback, useEffect, useState } from 'react'; -import { isOntimeEvent, OntimeEvent } from 'ontime-types'; +import { Button } from '@chakra-ui/react'; +import { CustomFieldLabel, isOntimeEvent, OntimeEvent } from 'ontime-types'; import CopyTag from '../../../common/components/copy-tag/CopyTag'; import { useEventAction } from '../../../common/hooks/useEventAction'; +import useCustomFields from '../../../common/hooks-query/useCustomFields'; import useRundown from '../../../common/hooks-query/useRundown'; import { useEventSelection } from '../useEventSelection'; import EventEditorTimes from './composite/EventEditorTimes'; import EventEditorTitles from './composite/EventEditorTitles'; import EventEditorUser from './composite/EventEditorUser'; +import EventTextArea from './composite/EventTextArea'; import style from './EventEditor.module.scss'; @@ -31,11 +34,13 @@ export type EditorUpdateFields = | 'user6' | 'user7' | 'user8' - | 'user9'; + | 'user9' + | CustomFieldLabel; // TODO: keyof customFields export default function EventEditor() { const selectedEvents = useEventSelection((state) => state.selectedEvents); const { data } = useRundown(); + const { data: customFields } = useCustomFields(); const { order, rundown } = data; const { updateEvent } = useEventAction(); @@ -63,7 +68,12 @@ export default function EventEditor() { const handleSubmit = useCallback( (field: EditorUpdateFields, value: string) => { - updateEvent({ id: event?.id, [field]: value }); + if (field.startsWith('custom-')) { + const fieldLabel = field.split('custom-')[1]; + updateEvent({ id: event?.id, custom: { [fieldLabel]: { value } } }); + } else { + updateEvent({ id: event?.id, [field]: value }); + } }, [event?.id, updateEvent], ); @@ -91,6 +101,8 @@ export default function EventEditor() { user9: event.user9, }; + const customKeys = Object.keys(customFields ?? {}); + return (
@@ -120,6 +132,25 @@ export default function EventEditor() { colour={event.colour} handleSubmit={handleSubmit} /> +
+
+ Custom Fields + +
+ {customKeys.map((label) => { + return ( + + ); + })} +
diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 58ac79de4..22190e69c 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -43,7 +43,7 @@ import { restoreService } from './services/RestoreService.js'; import { messageService } from './services/message-service/MessageService.js'; import { populateDemo } from './modules/loadDemo.js'; import { getState, updateRundownData } from './stores/runtimeState.js'; -import { setRundown } from './services/rundown-service/RundownService.js'; +import { initRundown } from './services/rundown-service/RundownService.js'; import { getPlayableEvents } from './services/rundown-service/rundownUtils.js'; import { generateCrashReport } from './utils/generateCrashReport.js'; @@ -183,7 +183,8 @@ export const startServer = async () => { // initialise rundown service const persistedRundown = DataProvider.getRundown(); - setRundown(persistedRundown); + const persistedCustomFields = DataProvider.getCustomFields(); + initRundown(persistedRundown, persistedCustomFields); // TODO: do this on the init of the runtime service updateRundownData(getPlayableEvents()); @@ -274,6 +275,7 @@ export const shutdown = async (exitCode = 0) => { await restoreService.clear(); } + // TODO: Clear token expressServer?.close(); oscServer?.shutdown(); runtimeService.shutdown(); diff --git a/apps/server/src/classes/data-provider/DataProvider.ts b/apps/server/src/classes/data-provider/DataProvider.ts index 6cd09b875..b81387e7e 100644 --- a/apps/server/src/classes/data-provider/DataProvider.ts +++ b/apps/server/src/classes/data-provider/DataProvider.ts @@ -17,6 +17,7 @@ import { import { data, db } from '../../modules/loadDb.js'; import { safeMerge } from './DataProvider.utils.js'; +import { isProduction } from '../../setup.js'; export class DataProvider { static getData() { @@ -109,6 +110,9 @@ export class DataProvider { } static async persist() { + if (!isProduction) { + return; + } await db.write(); } 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 88ab76aa3..9645ad8f8 100644 --- a/apps/server/src/classes/data-provider/__tests__/DataProvider.test.ts +++ b/apps/server/src/classes/data-provider/__tests__/DataProvider.test.ts @@ -42,8 +42,8 @@ describe('safeMerge', () => { user9: 'existing user9', }, customFields: { - lighting: { type: 'string', label: 'lighting' }, - vfx: { type: 'string', label: 'vfx' }, + lighting: { type: 'string', label: 'lighting', colour: 'red' }, + vfx: { type: 'string', label: 'vfx', colour: 'blue' }, }, osc: { portIn: 8888, diff --git a/apps/server/src/config/config.ts b/apps/server/src/config/config.ts index 41f1c9225..5994efe5b 100644 --- a/apps/server/src/config/config.ts +++ b/apps/server/src/config/config.ts @@ -12,6 +12,9 @@ export const config = { directory: 'demo', filename: ['app.js', 'index.html', 'styles.css'], }, + sheets: { + directory: 'sheets', + }, restoreFile: 'ontime.restore', }; diff --git a/apps/server/src/controllers/ontimeController.ts b/apps/server/src/controllers/ontimeController.ts index dc2bdc011..75e2974d3 100644 --- a/apps/server/src/controllers/ontimeController.ts +++ b/apps/server/src/controllers/ontimeController.ts @@ -1,4 +1,3 @@ -import { LogOrigin } from 'ontime-types'; import type { Alias, DatabaseModel, @@ -32,14 +31,12 @@ import { } from '../setup.js'; import { oscIntegration } from '../services/integration-service/OscIntegration.js'; import { httpIntegration } from '../services/integration-service/HttpIntegration.js'; -import { logger } from '../classes/Logger.js'; import { notifyChanges, setRundown } from '../services/rundown-service/RundownService.js'; import { getProjectFiles } from '../utils/getFileListFromFolder.js'; import { configService } from '../services/ConfigService.js'; import { deleteFile } from '../utils/parserUtils.js'; import { validateProjectFiles } from './ontimeController.validate.js'; import { dbModel } from '../models/dataModel.js'; -import { sheet } from '../utils/sheetsAuth.js'; import { removeFileExtension } from '../utils/removeFileExtension.js'; import type { OntimeError } from '../utils/backend.types.js'; import { ensureJsonExtension } from '../utils/ensureJsonExtension.js'; @@ -95,7 +92,7 @@ export type ParsingOptions = { /** * parse an uploaded file and apply its parsed objects * @param file - * @param req + * @param _req * @param res * @param [options] * @returns {Promise} @@ -277,7 +274,6 @@ export const postSettings = async (req: Request, res: Response) => { /** * @description Get view Settings - * @method GET */ export const getViewSettings = async (_req: Request, res: Response) => { const views = DataProvider.getViewSettings(); @@ -286,7 +282,6 @@ export const getViewSettings = async (_req: Request, res: Response) => { /** * @description Change view Settings - * @method POST */ export const postViewSettings = async (req: Request, res: Response) => { if (failEmptyObjects(req.body, res)) { @@ -410,7 +405,7 @@ export const dbUpload = async (req: Request, res: Response) => { * uploads and parses an excel file * @returns parsed result */ -export async function previewExcel(req, res: Response) { +export async function previewExcel(req: Request, res: Response) { if (!req.file) { res.status(400).send({ message: 'File not found' }); return; @@ -428,10 +423,10 @@ export async function previewExcel(req, res: Response) { /** * Retrieves and lists all project files from the uploads directory. - * @param req + * @param _req * @param res */ -export const listProjects: RequestHandler = async (_, res: Response) => { +export const listProjects: RequestHandler = async (_req, res: Response) => { try { const fileList = await getProjectFiles(); @@ -637,125 +632,3 @@ export const deleteProjectFile: RequestHandler = async (req: Request, res: Respo res.status(500).send({ message: String(error) }); } }; - -// SHEET Functions -/** - * @description SETP-1 POST Client Secrect - * @returns parsed result - */ -export async function uploadSheetClientFile(req, res: Response) { - if (!req.file.path) { - res.status(400).send({ message: 'File not found' }); - return; - } - try { - const client = JSON.parse(fs.readFileSync(req.file.path as string, 'utf-8')); - await sheet.saveClientSecrets(client); - res.status(200).send('OK'); - } catch (error) { - res.status(500).send({ message: String(error) }); - } - fs.unlink(req.file.path, (err) => { - if (err) logger.error(LogOrigin.Server, err.message); - }); -} - -/** - * @description STEP-1 GET Client Secret status - */ -export const getClientSecret = async (req: Request, res: Response) => { - try { - // TODO: can we merge this with the previous? - const clientSecretExists = await sheet.testClientSecret(); - if (clientSecretExists) { - res.status(200).send(); - } else { - res.status(500).send({ message: 'The Client ID does not exist' }); - } - } catch (error) { - res.status(500).send({ message: String(error) }); - } -}; - -/** - * @description STEP-2 GET sheet authentication url - */ -export async function getAuthenticationUrl(_req: Request, res: Response) { - try { - const authUrl = await sheet.openAuthServer(); - res.status(200).send(authUrl); - } catch (error) { - res.status(500).send({ message: String(error) }); - } -} - -/** - * @description STEP-2 GET sheet authentication status - */ -export const getAuthentication = async (_req: Request, res: Response) => { - try { - await sheet.testAuthentication(); - res.status(200).send(); - } catch (error) { - res.status(500).send({ message: String(error) }); - } -}; - -/** - * @description STEP-3 POST sheet id - * @returns list of worksheets - */ -export const postId = async (req: Request, res: Response) => { - try { - const { sheetId } = req.body; - if (sheetId.length < 40) { - res.status(400).send({ message: 'ID is usually 44 characters long' }); - } - const state = await sheet.testSheetId(sheetId); - res.status(200).send(state); - } catch (error) { - res.status(500).send({ message: String(error) }); - } -}; - -/** - * @description STEP-4 POST worksheet - */ -export const postWorksheet = async (req: Request, res: Response) => { - try { - const { sheetId, worksheet } = req.body; - const state = await sheet.testWorksheet(sheetId, worksheet); - res.status(200).send(state); - } catch (error) { - res.status(500).send({ message: String(error) }); - } -}; - -/** - * @description STEP-5 POST download rundown to sheet - * @returns parsed result - */ -export async function pullSheet(req: Request, res: Response) { - try { - const { sheetId, options } = req.body; - console.log('starting'); - const data = await sheet.pull(sheetId, options); - console.log('finished'); - res.status(200).send(data); - } catch (error) { - res.status(500).send({ message: String(error) }); - } -} - -/** - * @description STEP-5 POST upload rundown to sheet - */ -export async function pushSheet(req: Request, res: Response) { - try { - const { sheetId, options } = req.body; - await sheet.push(sheetId, options); - res.status(200).send(); - } 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 8d01d9f92..c617722fa 100644 --- a/apps/server/src/controllers/ontimeController.validate.ts +++ b/apps/server/src/controllers/ontimeController.validate.ts @@ -191,28 +191,6 @@ export const validateProjectRename = [ }, ]; -/** - * @description Validates the filename for creating a project file. - */ -export const validateProjectCreate = [ - body('filename') - .exists() - .withMessage('Filename is required') - .isString() - .withMessage('Filename must be a string') - .isLength({ min: 1, max: 255 }) - .withMessage('Filename must be between 1 and 255 characters'), - - (req: Request, res: Response, next: NextFunction) => { - const errors = validationResult(req); - if (!errors.isEmpty()) { - return res.status(422).json({ errors: errors.array() }); - } - - next(); - }, -]; - /** * @description Validates the existence of project files. * @param {object} projectFiles @@ -243,35 +221,3 @@ export const validateProjectFiles = (projectFiles: { filename?: string; newFilen return errors; }; - -export const validateSheetId = [ - body('sheetId').exists().isString(), - - (req: Request, res: Response, next: NextFunction) => { - const errors = validationResult(req); - if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() }); - next(); - }, -]; - -export const validateWorksheet = [ - body('sheetId').exists().isString(), - body('worksheet').exists().isString(), - - (req: Request, res: Response, next: NextFunction) => { - const errors = validationResult(req); - if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() }); - next(); - }, -]; - -export const validateSheetOptions = [ - body('sheetId').exists().isString(), - // body('options').exists().isObject(), TODO: - - (req: Request, res: Response, next: NextFunction) => { - const errors = validationResult(req); - if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() }); - next(); - }, -]; diff --git a/apps/server/src/controllers/projectController.ts b/apps/server/src/controllers/projectController.ts index 2996a1568..987d4e716 100644 --- a/apps/server/src/controllers/projectController.ts +++ b/apps/server/src/controllers/projectController.ts @@ -5,7 +5,12 @@ import { CustomField, CustomFields, ProjectData } from 'ontime-types'; import { removeUndefined } from '../utils/parserUtils.js'; import { failEmptyObjects } from '../utils/routerUtils.js'; import { DataProvider } from '../classes/data-provider/DataProvider.js'; -import { createCustomField, editCustomField, removeCustomField } from '../utils/customFields.js'; +import { + createCustomField, + editCustomField, + getCustomFields as getCustomFieldsFromCache, + removeCustomField, +} from '../services/rundown-service/rundownCache.js'; // Create controller for GET request to 'project' export const getProject: RequestHandler = async (req, res) => { @@ -36,15 +41,12 @@ export const postProject: RequestHandler = async (req, res) => { }; export const getCustomFields: RequestHandler = async (_req: Request, res: Response) => { - res.json(DataProvider.getCustomFields()); + const customFields = getCustomFieldsFromCache(); + res.json(customFields); }; -// Expects { label: type: 'string | ..' } +// Expects { label: