diff --git a/apps/client/src/common/api/ontimeApi.ts b/apps/client/src/common/api/ontimeApi.ts index 389df3522..29e6e2385 100644 --- a/apps/client/src/common/api/ontimeApi.ts +++ b/apps/client/src/common/api/ontimeApi.ts @@ -275,7 +275,8 @@ export const uploadSheetClientFile = async (file: File) => { /** * @description STEP 1 test */ -export const getClientSecrect = async () => { +// TODO: do we still need this? +export const getClientSecret = async () => { const response = await axios.get(`${ontimeURL}/sheet/clientsecret`); return response.data; }; @@ -300,32 +301,32 @@ export const getAuthentication = async () => { * @description STEP 3 * @returns worksheetOptions */ -export const postId = async (id: string) => { - const response = await axios.post(`${ontimeURL}/sheet/id`, { id }); +export const postId = async (sheetId: string) => { + const response = await axios.post(`${ontimeURL}/sheet/sheetId`, { sheetId }); return response.data; }; /** * @description STEP 4 */ -export const postWorksheet = async (id: string, worksheet: string) => { - const response = await axios.post(`${ontimeURL}/sheet/worksheet`, { id, worksheet }); +export const postWorksheet = async (sheetId: string, worksheet: string) => { + const response = await axios.post(`${ontimeURL}/sheet/worksheet`, { sheetId, worksheet }); return response.data; }; /** * @description STEP 5 */ -export const postPreviewSheet = async (id: string, options: ExcelImportMap) => { - const response = await axios.post(`${ontimeURL}/sheet/pull`, { id, options }); +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 (id: string, options: ExcelImportMap) => { - const response = await axios.post(`${ontimeURL}/sheet-push`, { id, options }); +export const postPushSheet = async (sheetId: string, options: ExcelImportMap) => { + const response = await axios.post(`${ontimeURL}/sheet-push`, { sheetId, options }); return response.data.data; }; diff --git a/apps/client/src/common/components/context-menu/ContextMenu.tsx b/apps/client/src/common/components/context-menu/ContextMenu.tsx index 1726bafaa..56e024f7d 100644 --- a/apps/client/src/common/components/context-menu/ContextMenu.tsx +++ b/apps/client/src/common/components/context-menu/ContextMenu.tsx @@ -68,7 +68,7 @@ export const ContextMenu = ({ children }: ContextMenuProps) => { <> {children}
- + (props: PropsWithCh return ( - - - name={name} - submitHandler={submitHandler} - time={time} - placeholder={placeholder} - className={style.inputField} - disabled={disabled} - /> - + + name={name} + submitHandler={submitHandler} + time={time} + placeholder={placeholder} + className={style.inputField} + disabled={disabled} + /> {children} ); diff --git a/apps/client/src/features/app-settings/AppSettings.tsx b/apps/client/src/features/app-settings/AppSettings.tsx index 36103aebf..1a5483822 100644 --- a/apps/client/src/features/app-settings/AppSettings.tsx +++ b/apps/client/src/features/app-settings/AppSettings.tsx @@ -7,6 +7,7 @@ import IntegrationsPanel from './panel/integrations-panel/IntegrationsPanel'; import LogPanel from './panel/log-panel/LogPanel'; import ProjectPanel from './panel/project-panel/ProjectPanel'; import ProjectSettingsPanel from './panel/project-settings-panel/ProjectSettingsPanel'; +import SourcesPanel from './panel/sources-panel/SourcesPanel'; import PanelContent from './panel-content/PanelContent'; import PanelList from './panel-list/PanelList'; import { useSettingsStore } from './settingsStore'; @@ -28,6 +29,7 @@ export default function AppSettings() { {selectedPanel === 'project' && } + {selectedPanel === 'sources' && } {selectedPanel === 'integrations' && } {selectedPanel === 'project_settings' && } {selectedPanel === 'about' && } 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 cd7399627..a126c0cf6 100644 --- a/apps/client/src/features/app-settings/panel/Panel.module.scss +++ b/apps/client/src/features/app-settings/panel/Panel.module.scss @@ -17,11 +17,13 @@ $inner-padding: 1rem; } .title { - font-size: 1.25rem; - padding-left: $inner-padding; + font-size: 1.375rem; + padding: 0 2rem; + font-weight: 600; display: flex; align-items: center; justify-content: space-between; + color: $gray-300; } .section { @@ -49,7 +51,7 @@ $inner-padding: 1rem; .pad { padding: 0 1rem; - max-height: 500px; + max-height: 550px; overflow-y: scroll; } @@ -80,7 +82,7 @@ $inner-padding: 1rem; } .listGroup { - padding: 0.5rem 1rem 0 1rem; + padding: 1rem 2rem 0 2rem; > li:not(:last-child) { border-bottom: 1px solid $white-10; diff --git a/apps/client/src/features/app-settings/panel/about-panel/AboutPanel.tsx b/apps/client/src/features/app-settings/panel/about-panel/AboutPanel.tsx index 60496e2ac..b85dfba61 100644 --- a/apps/client/src/features/app-settings/panel/about-panel/AboutPanel.tsx +++ b/apps/client/src/features/app-settings/panel/about-panel/AboutPanel.tsx @@ -17,18 +17,14 @@ export default function AboutPanel() { - - Links - Read the docs over at GitBook - Follow the project on GitHub - + Links + Read the docs over at GitBook + Follow the project on GitHub - - Current version - {`You are currently using Ontime ${version}`} - - + Current version + {`You are currently using Ontime ${version}`} + ); diff --git a/apps/client/src/features/app-settings/panel/about-panel/CheckUpdatesButton.tsx b/apps/client/src/features/app-settings/panel/about-panel/CheckUpdatesButton.tsx index 1732a9689..e0b9eaeb4 100644 --- a/apps/client/src/features/app-settings/panel/about-panel/CheckUpdatesButton.tsx +++ b/apps/client/src/features/app-settings/panel/about-panel/CheckUpdatesButton.tsx @@ -52,7 +52,7 @@ export default function CheckUpdatesButton(props: CheckUpdatesButtonProps) { return ( <> - diff --git a/apps/client/src/features/app-settings/panel/integrations-panel/IntegrationsPanel.module.css b/apps/client/src/features/app-settings/panel/integrations-panel/IntegrationsPanel.module.css index c11bd6e4f..623b23637 100644 --- a/apps/client/src/features/app-settings/panel/integrations-panel/IntegrationsPanel.module.css +++ b/apps/client/src/features/app-settings/panel/integrations-panel/IntegrationsPanel.module.css @@ -2,8 +2,8 @@ width: 100%; } -.fitContents { - width: max-content !important; /* override chakra */ +.fitContents.fitContents { + width: max-content; /* override chakra */ } .flex { diff --git a/apps/client/src/features/app-settings/panel/integrations-panel/IntegrationsPanel.tsx b/apps/client/src/features/app-settings/panel/integrations-panel/IntegrationsPanel.tsx index b8a2e974f..f78519645 100644 --- a/apps/client/src/features/app-settings/panel/integrations-panel/IntegrationsPanel.tsx +++ b/apps/client/src/features/app-settings/panel/integrations-panel/IntegrationsPanel.tsx @@ -16,7 +16,7 @@ export default function IntegrationsPanel() { - Integrations allow Ontime to receive commands or send its data to other systems in your workflow.
{' '} + Integrations allow Ontime to receive commands or send its data to other systems in your workflow.

Currently supported protocols are OSC (Open Sound Control), HTTP and Websockets.
WebSockets are used for Ontime and cannot be configured independently.
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 26b94eb71..b833b163a 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 @@ -97,7 +97,11 @@ export default function OscIntegrations() {
+ + + + OSC Settings {errors?.root && {errors.root.message}} diff --git a/apps/client/src/features/app-settings/panel/project-panel/ProjectPanel.tsx b/apps/client/src/features/app-settings/panel/project-panel/ProjectPanel.tsx index 156bae053..f5a0d31a4 100644 --- a/apps/client/src/features/app-settings/panel/project-panel/ProjectPanel.tsx +++ b/apps/client/src/features/app-settings/panel/project-panel/ProjectPanel.tsx @@ -19,9 +19,14 @@ export default function ProjectPanel() { Manage projects - +
+ + +
diff --git a/apps/client/src/features/app-settings/panel/sources-panel/GSheetInfo.tsx b/apps/client/src/features/app-settings/panel/sources-panel/GSheetInfo.tsx new file mode 100644 index 000000000..959ad4cca --- /dev/null +++ b/apps/client/src/features/app-settings/panel/sources-panel/GSheetInfo.tsx @@ -0,0 +1,21 @@ +import { Alert, AlertDescription, AlertIcon } from '@chakra-ui/react'; + +import ExternalLink from '../../../../common/components/external-link/ExternalLink'; +const googleSheetDocsUrl = 'https://ontime.gitbook.io/v2/features/google-sheet'; + +export default function GSheetInfo() { + return ( + + + + Ontime allows you to synchronize your rundown with a Google Sheet. +
+
+ To enable this feature, you will need to generate tokens in your Google account and provide them to Ontime. +
+ Once set up, you will be able to synchronize data between Ontime and your Google Sheet.
+ 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 new file mode 100644 index 000000000..5fb2214ec --- /dev/null +++ b/apps/client/src/features/app-settings/panel/sources-panel/GSheetSetup.tsx @@ -0,0 +1,151 @@ +import { useRef } from 'react'; +import { Button, Input, Select } 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 * as Panel from '../PanelUtils'; + +import useGoogleSheet from './useGoogleSheet'; +import { useSheetStore } from './useSheetStore'; + +import style from './SourcesPanel.module.scss'; + +interface GSheetSetupProps { + cancel: () => 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); + + 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); + + // user cancels the flow + const onCancel = () => { + reset(); + cancel(); + }; + + // connect to the accoutn with the given sheet ID + const connectToId = () => { + const sheetId = sheetIdInputRef.current?.value; + if (!sheetId) return; + + handleConnect(sheetId); + }; + + // 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); + }; + + // 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; + + return ( + + + Sync with Google Sheet (experimental) + + + +
+
+ +
+ +
+ {stepData.clientSecret.error} +
+ + + {stepData.sheetId.error} +
+
+ +
+ +
+
+ + +
+
+ +
+ +
+ {stepData.worksheet.error} + {stepData.pullPush.error} +
+
+ ); +} 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 new file mode 100644 index 000000000..a59066855 --- /dev/null +++ b/apps/client/src/features/app-settings/panel/sources-panel/ImportMap.tsx @@ -0,0 +1,45 @@ +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 { handleImportPreview, handleExport } = 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 exportRundown = () => { + if (!worksheetId || !sheetId) return; + handleExport(sheetId, worksheetId, importOptions); + }; + + const importPreviewRundown = () => { + if (!worksheetId || !sheetId) return; + handleImportPreview(sheetId, worksheetId, importOptions); + }; + + 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 new file mode 100644 index 000000000..04bb09c39 --- /dev/null +++ b/apps/client/src/features/app-settings/panel/sources-panel/ImportReview.tsx @@ -0,0 +1,37 @@ +import { Button } from '@chakra-ui/react'; +import { OntimeRundown, UserFields } from 'ontime-types'; + +import PreviewExcel from '../../../modals/upload-modal/preview/PreviewExcel'; + +import useGoogleSheet from './useGoogleSheet'; +import { useSheetStore } from './useSheetStore'; + +import style from './SourcesPanel.module.scss'; + +interface ImportReviewProps { + rundown: OntimeRundown; + userFields: UserFields; +} + +export default function ImportReview({ rundown, userFields }: ImportReviewProps) { + const { handleImport } = useGoogleSheet(); + const resetPreview = useSheetStore((state) => state.resetPreview); + + const applyImport = () => { + handleImport(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 new file mode 100644 index 000000000..e7e4d67bd --- /dev/null +++ b/apps/client/src/features/app-settings/panel/sources-panel/SourcesPanel.module.scss @@ -0,0 +1,21 @@ +.uploadSection { + margin-top: 1rem; + display: flex; + flex-direction: row; + gap: 2rem; + padding: 3rem 1rem; + align-items: center; + justify-content: center; + background-color: $gray-1350; + border: 1px solid $white-10; + border-radius: 3px; +} + +.buttonRow { + display: flex; + gap: 1rem; +} + +.inputContainer { + flex: 1; +} 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 new file mode 100644 index 000000000..ae1592e6d --- /dev/null +++ b/apps/client/src/features/app-settings/panel/sources-panel/SourcesPanel.tsx @@ -0,0 +1,84 @@ +import { 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 * as Panel from '../PanelUtils'; + +import GSheetInfo from './GSheetInfo'; +import GSheetSetup from './GSheetSetup'; +import ImportMap from './ImportMap'; +import ImportReview from './ImportReview'; +import { useSheetStore } from './useSheetStore'; + +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 rundown = useSheetStore((state) => state.rundown); + const userFields = useSheetStore((state) => state.userFields); + const hasData = rundown && userFields; + + const fileInputRef = useRef(null); + + const handleFile = () => console.error('not yet implementeed'); + + const handleUpload = () => { + fileInputRef.current?.click(); + setImportFlow('excel'); + }; + + const openGSheetFlow = () => { + setImportFlow('gsheet'); + }; + + const cancelGSheetFlow = () => { + setImportFlow('none'); + }; + + const isExcelFlow = importFlow === 'excel'; + const isGSheetFlow = importFlow === 'gsheet'; + + return ( + <> + Data sources + + + Synchronise your rundown with an external source + + {!isGSheetFlow && ( + <> + +
+
+ + Accepts .xlsx files +
+
+ + Start authentication process +
+
+ + )} + {isGSheetFlow && } + {isExcelFlow && Not yet implemented} + {hasDataSource && } + {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 new file mode 100644 index 000000000..939c79a70 --- /dev/null +++ b/apps/client/src/features/app-settings/panel/sources-panel/useGoogleSheet.ts @@ -0,0 +1,152 @@ +import { ChangeEvent } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { 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, +} 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; + } + + 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: '' }, + }); + } + }; + + /** authenticate with the Google Sheets API */ + const handleAuthenticate = async () => { + 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: '' }, + }); + } + }; + + /** fetches data from a Google Sheet by its ID */ + const handleConnect = async (sheetId: string) => { + 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([]); + } + }; + + /** fetches data from a worksheet by its ID */ + const handleImportPreview = async (sheetId: string, worksheet: string, fileOptions: ExcelImportMap) => { + try { + // update worksheet data in the server + await postWorksheet(sheetId, worksheet); + + // get data from google + const data = await postPreviewSheet(sheetId, fileOptions); + setRundown(data.rundown); + setUserFields(data.userFields); + } catch (error) { + patchStepData({ pullPush: { available: true, error: maybeAxiosError(error) } }); + } + }; + + /** writes data to a worksheet by its ID */ + const handleExport = async (sheetId: string, worksheet: string, fileOptions: ExcelImportMap) => { + try { + // update worksheet data in the server + await postWorksheet(sheetId, worksheet); + + // write data to google + await postPushSheet(sheetId, fileOptions); + patchStepData({ pullPush: { available: false, error: '' } }); + } catch (error) { + patchStepData({ pullPush: { available: true, error: maybeAxiosError(error) } }); + } + }; + + /** applies rundown and userfields to current project */ + const handleImport = async (rundown: OntimeRundown, userFields: UserFields) => { + try { + await patchData({ rundown, userFields }); + queryClient.setQueryData(RUNDOWN, rundown); + queryClient.setQueryData(USERFIELDS, userFields); + await queryClient.invalidateQueries({ + queryKey: [...RUNDOWN, ...USERFIELDS], + }); + } catch (error) { + patchStepData({ pullPush: { available: true, error: maybeAxiosError(error) } }); + } + }; + + return { + handleClientSecret, + handleAuthenticate, + handleConnect, + handleImportPreview, + handleImport, + handleExport, + }; +} 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 new file mode 100644 index 000000000..920e0e949 --- /dev/null +++ b/apps/client/src/features/app-settings/panel/sources-panel/useSheetStore.ts @@ -0,0 +1,67 @@ +import { 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; + reset: () => void; + resetPreview: () => void; +}; + +const initialStepData = { + clientSecret: { available: true, error: '' }, + authenticate: { available: false, error: '' }, + sheetId: { available: false, error: '' }, + worksheet: { available: false, error: '' }, + pullPush: { available: false, error: '' }, +}; + +const initialState = { + clientSecret: null, + 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 }), + 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/app-settings/settingsStore.ts b/apps/client/src/features/app-settings/settingsStore.ts index 1e6cfffbe..69b41b13f 100644 --- a/apps/client/src/features/app-settings/settingsStore.ts +++ b/apps/client/src/features/app-settings/settingsStore.ts @@ -16,14 +16,17 @@ export const settingPanels: Readonly = [ { id: 'project_settings', label: 'Project Settings', - secondary: [{ id: 'project_settings__custom', label: 'Custom Fields' }], + secondary: [{ id: 'project_settings__custom', label: 'Custom fields' }], }, { id: 'interface', label: 'Interface' }, { id: 'views', label: 'Views' }, { id: 'sources', label: 'Data Sources', - secondary: [{ id: 'sources__gsheet', label: 'Sync with Google Sheet' }], + secondary: [ + { id: 'sources__xlsx', label: 'Import spreadsheet' }, + { id: 'sources__gsheet', label: 'Sync with Google Sheet' }, + ], split: true, }, { diff --git a/apps/client/src/features/editors/Editor.tsx b/apps/client/src/features/editors/Editor.tsx index cf37be836..0747bb096 100644 --- a/apps/client/src/features/editors/Editor.tsx +++ b/apps/client/src/features/editors/Editor.tsx @@ -5,7 +5,6 @@ import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary' import AppSettings from '../app-settings/AppSettings'; import { SettingsOptionId, useSettingsStore } from '../app-settings/settingsStore'; import MenuBar from '../menu/MenuBar'; -import SheetsModal from '../modals/sheets-modal/SheetsModal'; import UploadModal from '../modals/upload-modal/UploadModal'; import Overview from '../overview/Overview'; @@ -26,7 +25,6 @@ export default function Editor() { const { isOpen: isOldSettingsOpen, onOpen: onSettingsOpen, onClose: onSettingsClose } = useDisclosure(); const { isOpen: isUploadModalOpen, onOpen: onUploadModalOpen, onClose: onUploadModalClose } = useDisclosure(); - const { isOpen: isSheetsOpen, onOpen: onSheetsOpen, onClose: onSheetsClose } = useDisclosure(); // Set window title useEffect(() => { @@ -40,7 +38,6 @@ export default function Editor() { -
@@ -52,8 +49,6 @@ export default function Editor() { onUploadOpen={onUploadModalOpen} openSettings={handleSettings} isSettingsOpen={isSettingsOpen} - isSheetsOpen={isSheetsOpen} - onSheetsOpen={onSheetsOpen} /> {showSettings ? ( diff --git a/apps/client/src/features/menu/MenuBar.tsx b/apps/client/src/features/menu/MenuBar.tsx index 29282251e..a4efe788d 100644 --- a/apps/client/src/features/menu/MenuBar.tsx +++ b/apps/client/src/features/menu/MenuBar.tsx @@ -1,8 +1,6 @@ import { memo, useCallback, useEffect } from 'react'; import { IconButton, MenuButton, Tooltip } from '@chakra-ui/react'; import { IoAdd } from '@react-icons/all-files/io5/IoAdd'; -import { IoCloud } from '@react-icons/all-files/io5/IoCloud'; -import { IoCloudOutline } from '@react-icons/all-files/io5/IoCloudOutline'; import { IoOptions } from '@react-icons/all-files/io5/IoOptions'; import { IoPlay } from '@react-icons/all-files/io5/IoPlay'; import { IoPushOutline } from '@react-icons/all-files/io5/IoPushOutline'; @@ -25,8 +23,6 @@ interface MenuBarProps { onSettingsClose: () => void; isUploadOpen: boolean; onUploadOpen: () => void; - isSheetsOpen: boolean; - onSheetsOpen: () => void; openSettings: (newTab?: string) => void; isSettingsOpen: boolean; } @@ -52,8 +48,6 @@ const MenuBar = (props: MenuBarProps) => { onUploadOpen, openSettings, isSettingsOpen, - isSheetsOpen, - onSheetsOpen, } = props; const { isElectron, sendToElectron } = useElectronEvent(); @@ -141,15 +135,6 @@ const MenuBar = (props: MenuBarProps) => { aria-label='Edit mode' />
- : } - className={isSheetsOpen ? style.open : ''} - clickHandler={onSheetsOpen} - tooltip='Sheets' - aria-label='Sheets' - /> void; - isOpen: boolean; -} - -export default function SheetsModal(props: SheetsModalProps) { - const { isOpen, onClose } = props; - - const queryClient = useQueryClient(); - - const [rundown, setRundown] = useState(null); - const [userFields, setUserFields] = useState(null); - - const [id, setSheetId] = useState(''); - const [worksheet, setWorksheet] = useState(''); - const [worksheetOptions, setWorksheetOptions] = useState([]); - - const [direction, setDirection] = useState('none'); - const excelFileOptions = useRef(defaultExcelImportMap); - - const fileInputRef = useRef(null); - - const [state, setState] = useState({ - clientSecret: { complete: false, message: '' }, - authenticate: { complete: false, message: '' }, - id: { complete: false, message: '' }, - worksheet: { complete: false, message: '' }, - pullPush: { complete: false, message: '' }, - }); - - useEffect(() => { - if (isOpen) { - setDirection('none'); - testClientSecret(); - if (state.clientSecret.complete) testAuthentication(); - if (state.authenticate.complete) testSheetId(); - } - }, [isOpen]); - - const handleClose = () => { - setRundown(null); - setUserFields(null); - onClose(); - }; - - //STEP-1 Upload Client ID - const handleClick = () => { - fileInputRef.current?.click(); - }; - - const handleFile = (event: ChangeEvent) => { - if (!event.target.files?.length) { - setState({ - clientSecret: { complete: false, message: 'Missing file' }, - authenticate: { complete: false, message: '' }, - id: { complete: false, message: '' }, - worksheet: { complete: false, message: '' }, - pullPush: { complete: false, message: '' }, - }); - return; - } - const selectedFile = event.target.files[0]; - uploadSheetClientFile(selectedFile) - .then(() => { - setState({ ...state, clientSecret: { complete: true, message: '' } }); - }) - .catch((err) => { - const message = maybeAxiosError(err); - setState({ - clientSecret: { complete: false, message }, - authenticate: { complete: false, message: '' }, - id: { complete: false, message: '' }, - worksheet: { complete: false, message: '' }, - pullPush: { complete: false, message: '' }, - }); - }); - }; - - const testClientSecret = () => { - getClientSecrect() - .then(() => { - setState({ ...state, clientSecret: { complete: true, message: '' } }); - }) - .catch((err) => { - const message = maybeAxiosError(err); - setState({ - clientSecret: { complete: false, message }, - authenticate: { complete: false, message: '' }, - id: { complete: false, message: '' }, - worksheet: { complete: false, message: '' }, - pullPush: { complete: false, message: '' }, - }); - }); - }; - - //STEP-2 Authenticate - const handleAuthenticate = () => { - getSheetsAuthUrl() - .then((data) => { - openLink(data); - window.addEventListener('focus', () => testAuthentication(), { once: true }); - }) - .catch((err) => { - const message = maybeAxiosError(err); - setState({ - ...state, - authenticate: { complete: false, message }, - id: { complete: false, message: '' }, - worksheet: { complete: false, message: '' }, - pullPush: { complete: false, message: '' }, - }); - }); - }; - - const testAuthentication = () => { - getAuthentication() - .then(() => { - setState({ ...state, authenticate: { complete: true, message: '' } }); - }) - .catch((error) => { - const message = maybeAxiosError(error); - setState({ - ...state, - authenticate: { complete: false, message }, - id: { complete: false, message: '' }, - worksheet: { complete: false, message: '' }, - pullPush: { complete: false, message: '' }, - }); - }); - }; - - //STEP-3 set sheet ID - const testSheetId = () => { - postId(id) - .then((data) => { - setState({ ...state, id: { complete: true, message: '' } }); - setWorksheetOptions(data.worksheetOptions); - }) - .catch((err) => { - const message = maybeAxiosError(err); - setState({ - ...state, - id: { complete: false, message }, - worksheet: { complete: false, message: '' }, - pullPush: { complete: false, message: '' }, - }); - setWorksheetOptions([]); - }); - }; - - //STEP-4 Select Worksheet - const testWorksheet = (value: string) => { - excelFileOptions.current.worksheet = value; - setWorksheet(value); - postWorksheet(id, worksheet) - .then(() => { - setState({ ...state, worksheet: { complete: true, message: '' } }); - }) - .catch((err) => { - const message = maybeAxiosError(err); - setState({ ...state, worksheet: { complete: false, message }, pullPush: { complete: false, message: '' } }); - }); - }; - - //STEP-5 Upload / Download - const updateExcelFileOptions = (field: T, value: ExcelImportMap[T]) => { - if (excelFileOptions.current[field] !== value) { - excelFileOptions.current = { ...excelFileOptions.current, [field]: value }; - } - }; - - const handlePullData = () => { - postPreviewSheet(id, excelFileOptions.current) - .then((data) => { - setRundown(data.rundown); - setUserFields(data.userFields); - }) - .catch((error) => { - const message = maybeAxiosError(error); - setDirection('none'); - setState({ ...state, pullPush: { complete: false, message } }); - }); - }; - - const handlePushData = () => { - postPushSheet(id, excelFileOptions.current) - .then(() => { - setDirection('none'); - setState({ ...state, pullPush: { complete: true, message: '' } }); - }) - .catch((error) => { - const message = maybeAxiosError(error); - setDirection('none'); - setState({ ...state, pullPush: { complete: false, message } }); - }); - }; - - //GET preview - const handleFinalise = async () => { - if (rundown && userFields) { - let doClose = false; - try { - await patchData({ rundown, userFields }); - queryClient.setQueryData(RUNDOWN, rundown); - queryClient.setQueryData(USERFIELDS, userFields); - await queryClient.invalidateQueries({ - queryKey: [...RUNDOWN, ...USERFIELDS], - }); - doClose = true; - } catch (error) { - const message = maybeAxiosError(error); - console.error(message); - } finally { - if (doClose) { - handleClose(); - } - } - } - }; - - return ( - - - - Rundown from sheets (experimental) - - - - -
- Sync with Google Sheets - - - For more information, see the docs - - -
-
- {!rundown ? ( - direction === 'up' || direction === 'down' ? ( - - ) : ( - <> - - -
- - -
-
- - -
- - -
-
- - - - setSheetId(event.target.value)} - /> - - - - - - - - - -
- - -
-
- - ) - ) : ( - - )} -
- - {rundown ? ( -
- - -
- ) : direction === 'up' ? ( -
- - -
- ) : direction === 'down' ? ( -
- - -
- ) : ( - <> - )} -
-
-
- ); -} diff --git a/apps/client/src/features/modals/sheets-modal/Step.module.scss b/apps/client/src/features/modals/sheets-modal/Step.module.scss deleted file mode 100644 index d8d004998..000000000 --- a/apps/client/src/features/modals/sheets-modal/Step.module.scss +++ /dev/null @@ -1,40 +0,0 @@ -.wrapper { - padding: 0.5rem; -} - -.header { - width: 100%; - display: flex; - align-items: center; - gap: 0.5rem; - font-size: 1rem; - padding-bottom: 1rem; -} - -.step { - width: 1.25rem; - height: 1.25rem; - font-size: 0.75rem; - display: grid; - place-content: center; -} - -.title { - display: inline; - color: $gray-500; - text-transform: uppercase; - cursor: pointer; -} - -.check { - color: $green-700; -} - -.errorIcon { - color: $red-700; -} - -.errorText { - color: $red-700; - font-size: 0.75rem; -} diff --git a/apps/client/src/features/modals/sheets-modal/Step.tsx b/apps/client/src/features/modals/sheets-modal/Step.tsx deleted file mode 100644 index 28f16b219..000000000 --- a/apps/client/src/features/modals/sheets-modal/Step.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { PropsWithChildren, useEffect, useMemo, useState } from 'react'; -import { IoCheckmarkCircle } from '@react-icons/all-files/io5/IoCheckmarkCircle'; -import { IoCloseCircle } from '@react-icons/all-files/io5/IoCloseCircle'; -import { IoRadioButtonOffOutline } from '@react-icons/all-files/io5/IoRadioButtonOffOutline'; - -import style from './Step.module.scss'; - -interface StepProps { - title: string; - disabled: boolean; - completed: boolean; - error?: string; -} - -export default function Step(props: PropsWithChildren) { - const { title, disabled, completed, error, children } = props; - const [collapsed, setCollapsed] = useState(disabled); - - const handleCollapse = () => setCollapsed((prev) => !prev); - - const icon = useMemo(() => { - if (completed) return ; - if (error) return ; - return ; - }, [completed, error]); - - useEffect(() => { - if (completed) { - setCollapsed(true); - } - }, [completed]); - - return ( -
-
- {icon} - {title} -
- {!collapsed && ( - <> - {error &&
{error}
} - {children} - - )} -
- ); -} diff --git a/apps/client/src/features/modals/upload-modal/UploadModal.tsx b/apps/client/src/features/modals/upload-modal/UploadModal.tsx index 23e88c6d4..c5872303e 100644 --- a/apps/client/src/features/modals/upload-modal/UploadModal.tsx +++ b/apps/client/src/features/modals/upload-modal/UploadModal.tsx @@ -207,7 +207,9 @@ export default function UploadModal({ onClose, isOpen }: UploadModalProps) { <> {isOntime && } - {isExcel && } + {isExcel && ( + + )} ) : ( 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 index 563eb6a8b..5333ab463 100644 --- a/apps/client/src/features/modals/upload-modal/upload-options/ExcelFileOptions.tsx +++ b/apps/client/src/features/modals/upload-modal/upload-options/ExcelFileOptions.tsx @@ -1,4 +1,3 @@ -import { MutableRefObject } from 'react'; import { ExcelImportMap } from 'ontime-utils'; import ImportMapTable, { type TableEntry } from './ImportMapTable'; @@ -6,50 +5,50 @@ import ImportMapTable, { type TableEntry } from './ImportMapTable'; import style from '../UploadModal.module.scss'; interface ExcelFileOptionsProps { - optionsRef: MutableRefObject; + importOptions: ExcelImportMap; updateOptions: (field: T, value: ExcelImportMap[T]) => void; } export default function ExcelFileOptions(props: ExcelFileOptionsProps) { - const { optionsRef, updateOptions } = props; + const { importOptions, updateOptions } = props; - const worksheet: TableEntry[] = [{ label: 'Worksheet', title: 'worksheet', value: optionsRef.current.worksheet }]; + const worksheet: TableEntry[] = [{ label: 'Worksheet', title: 'worksheet', value: importOptions.worksheet }]; const timings: TableEntry[] = [ - { label: 'Start time', title: 'timeStart', value: optionsRef.current.timeStart }, - { label: 'End Time', title: 'timeEnd', value: optionsRef.current.timeEnd }, - { label: 'Duration', title: 'duration', value: optionsRef.current.duration }, - { label: 'Warning Time', title: 'timeWarning', value: optionsRef.current.timeWarning }, - { label: 'Danger Time', title: 'timeDanger', value: optionsRef.current.timeDanger }, + { 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: optionsRef.current.cue }, - { label: 'Colour', title: 'colour', value: optionsRef.current.colour }, - { label: 'Title', title: 'title', value: optionsRef.current.title }, - { label: 'Presenter', title: 'presenter', value: optionsRef.current.presenter }, - { label: 'Subtitle', title: 'subtitle', value: optionsRef.current.subtitle }, - { label: 'Note', title: 'note', value: optionsRef.current.note }, + { 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: optionsRef.current.isPublic }, - { label: 'Skip', title: 'skip', value: optionsRef.current.skip }, - { label: 'Timer Type', title: 'timerType', value: optionsRef.current.timerType }, - { label: 'End Action', title: 'endAction', value: optionsRef.current.endAction }, + { 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: optionsRef.current.user0 }, - { label: 'User 1', title: 'user1', value: optionsRef.current.user1 }, - { label: 'User 2', title: 'user2', value: optionsRef.current.user2 }, - { label: 'User 3', title: 'user3', value: optionsRef.current.user3 }, - { label: 'User 4', title: 'user4', value: optionsRef.current.user4 }, - { label: 'User 5', title: 'user5', value: optionsRef.current.user5 }, - { label: 'User 6', title: 'user6', value: optionsRef.current.user6 }, - { label: 'User 7', title: 'user7', value: optionsRef.current.user7 }, - { label: 'User 8', title: 'user8', value: optionsRef.current.user8 }, - { label: 'User 9', title: 'user9', value: optionsRef.current.user9 }, + { 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.tsx b/apps/client/src/features/modals/upload-modal/upload-options/ImportMapTable.tsx index 1435d0875..bcbfa577f 100644 --- a/apps/client/src/features/modals/upload-modal/upload-options/ImportMapTable.tsx +++ b/apps/client/src/features/modals/upload-modal/upload-options/ImportMapTable.tsx @@ -31,8 +31,8 @@ export default function ImportMapTable(props: ImportMapTableProps) { { +export const getClientSecret = async (req: Request, res: Response) => { try { - const clientSecrectExists = await sheet.testClientSecret(); - if (clientSecrectExists) { + // 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' }); @@ -706,11 +707,11 @@ export const getAuthentication = async (_req: Request, res: Response) => { */ export const postId = async (req: Request, res: Response) => { try { - const { id } = req.body; - if (id.lenght < 40) { - res.status(400).send({ message: 'ID is usualy 44 characters long' }); + const { sheetId } = req.body; + if (sheetId.length < 40) { + res.status(400).send({ message: 'ID is usually 44 characters long' }); } - const state = await sheet.testSheetId(id); + const state = await sheet.testSheetId(sheetId); res.status(200).send(state); } catch (error) { res.status(500).send({ message: String(error) }); @@ -722,8 +723,8 @@ export const postId = async (req: Request, res: Response) => { */ export const postWorksheet = async (req: Request, res: Response) => { try { - const { worksheet, id } = req.body; - const state = await sheet.testWorksheet(worksheet, id); + 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) }); @@ -731,13 +732,15 @@ export const postWorksheet = async (req: Request, res: Response) => { }; /** - * @description STEP-5 POST download undown to sheet + * @description STEP-5 POST download rundown to sheet * @returns parsed result */ export async function pullSheet(req: Request, res: Response) { try { - const { id, options } = req.body; - const data = await sheet.pull(id, options); + 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) }); @@ -749,8 +752,8 @@ export async function pullSheet(req: Request, res: Response) { */ export async function pushSheet(req: Request, res: Response) { try { - const { id, options } = req.body; - await sheet.push(id, options); + 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 f385e6b82..8d01d9f92 100644 --- a/apps/server/src/controllers/ontimeController.validate.ts +++ b/apps/server/src/controllers/ontimeController.validate.ts @@ -244,8 +244,8 @@ export const validateProjectFiles = (projectFiles: { filename?: string; newFilen return errors; }; -export const validateSheetid = [ - body('id').exists().isString(), +export const validateSheetId = [ + body('sheetId').exists().isString(), (req: Request, res: Response, next: NextFunction) => { const errors = validationResult(req); @@ -255,7 +255,7 @@ export const validateSheetid = [ ]; export const validateWorksheet = [ - body('id').exists().isString(), + body('sheetId').exists().isString(), body('worksheet').exists().isString(), (req: Request, res: Response, next: NextFunction) => { @@ -266,7 +266,7 @@ export const validateWorksheet = [ ]; export const validateSheetOptions = [ - body('id').exists().isString(), + body('sheetId').exists().isString(), // body('options').exists().isObject(), TODO: (req: Request, res: Response, next: NextFunction) => { diff --git a/apps/server/src/routes/ontimeRouter.ts b/apps/server/src/routes/ontimeRouter.ts index a6957a827..4430909e8 100644 --- a/apps/server/src/routes/ontimeRouter.ts +++ b/apps/server/src/routes/ontimeRouter.ts @@ -31,7 +31,8 @@ import { pushSheet, postId, getAuthentication, - getClientSecrect as getClientSecret, + getClientSecret as getClientSecret, + postWorksheet, } from '../controllers/ontimeController.js'; import { @@ -45,7 +46,7 @@ import { validateProjectDuplicate, validateLoadProjectFile, validateProjectRename, - validateSheetid, + validateSheetId, validateWorksheet, validateSheetOptions, } from '../controllers/ontimeController.validate.js'; @@ -126,6 +127,7 @@ router.post('/project', projectSanitiser, createProjectFile); // create route between controller and '/ontime/project/:filename' endpoint router.delete('/project/:filename', sanitizeProjectFilename, deleteProjectFile); +// TODO: move the google sheet stuff into a separate file // Google Sheet integration - Step 1 router.post('/sheet/clientsecret', uploadFile, uploadClientSecret); router.get('/sheet/clientsecret', uploadFile, getClientSecret); @@ -135,13 +137,13 @@ router.get('/sheet/authentication/url', getAuthenticationUrl); router.get('/sheet/authentication', getAuthentication); // Google Sheet integration - Step 3 -router.post('/sheet/id', validateSheetid, postId); +router.post('/sheet/sheetId', validateSheetId, postId); // Google Sheet integration - Step 4 -router.post('/sheet/worksheet', validateWorksheet, postId); +router.post('/sheet/worksheet', validateWorksheet, postWorksheet); // Google Sheet integration - Step 5 -router.post('/sheet/pull', validateSheetOptions, pullSheet); +router.post('/sheet-pull', validateSheetOptions, pullSheet); // Google Sheet integration - Step 6 router.post('/sheet-push', validateSheetOptions, pushSheet); diff --git a/apps/server/src/utils/sheetsAuth.ts b/apps/server/src/utils/sheetsAuth.ts index 0dba04ad5..a283e0068 100644 --- a/apps/server/src/utils/sheetsAuth.ts +++ b/apps/server/src/utils/sheetsAuth.ts @@ -80,7 +80,7 @@ class Sheet { } /** - * @description STEP 1 - test that the saved object is pressent + * @description STEP 1 - test that the saved object is present */ testClientSecret() { return Sheet.clientSecret !== null; @@ -92,18 +92,15 @@ class Sheet { * @throws */ async openAuthServer(): Promise { - //TODO: this only works on local networks + //TODO: this only works in local networks - // if the server is allready running retun it + // if the server is already running return it if (Sheet.authUrl) { clearTimeout(this.authServerTimeout); - this.authServerTimeout = setTimeout( - () => { - Sheet.authUrl = null; - server.unref(); - }, - 2 * 60 * 1000, - ); + this.authServerTimeout = setTimeout(() => { + Sheet.authUrl = null; + server.unref(); + }, 120000); return Sheet.authUrl; } @@ -209,12 +206,12 @@ class Sheet { * @description STEP 3 - test the given sheet id * @throws */ - async testSheetId(id: string) { + async testSheetId(sheetId: string) { const spreadsheets = await sheets({ version: 'v4', auth: Sheet.client }).spreadsheets.get({ - spreadsheetId: id, + spreadsheetId: sheetId, includeGridData: false, }); - if (spreadsheets.status != 200) { + if (spreadsheets.status !== 200) { throw new Error(spreadsheets.statusText); } return { worksheetOptions: spreadsheets.data.sheets.map((i) => i.properties.title) }; @@ -224,12 +221,12 @@ class Sheet { * @description STEP 4 - test the given worksheet * @throws */ - async testWorksheet(id: string, worksheet: string) { + async testWorksheet(sheetId: string, worksheet: string) { const spreadsheets = await sheets({ version: 'v4', auth: Sheet.client }).spreadsheets.get({ - spreadsheetId: id, + spreadsheetId: sheetId, includeGridData: false, }); - if (spreadsheets.status != 200) { + if (spreadsheets.status !== 200) { throw new Error(spreadsheets.statusText); } const worksheetExist = spreadsheets.data.sheets.find((i) => i.properties.title === worksheet); @@ -250,17 +247,18 @@ class Sheet { spreadsheetId: sheetId, }); - if (spreadsheets.status === 200) { - const ourWorksheetData = spreadsheets.data.sheets.find((n) => n.properties.title == worksheet); - if (ourWorksheetData !== undefined) { - const endCell = getA1Notation( - ourWorksheetData.properties.gridProperties.rowCount, - ourWorksheetData.properties.gridProperties.columnCount, - ); - return { worksheetId: ourWorksheetData.properties.sheetId, range: `${worksheet}!A1:${endCell}` }; - } - } else { - throw new Error('Uable to open spreadsheets'); + if (spreadsheets.status !== 200) { + throw new Error(`Request failed: ${spreadsheets.status} ${spreadsheets.statusText}`); + } + + const ourWorksheetData = spreadsheets.data.sheets.find((n) => n.properties.title == worksheet); + + if (ourWorksheetData !== undefined) { + const endCell = getA1Notation( + ourWorksheetData.properties.gridProperties.rowCount, + ourWorksheetData.properties.gridProperties.columnCount, + ); + return { worksheetId: ourWorksheetData.properties.sheetId, range: `${worksheet}!A1:${endCell}` }; } } @@ -302,7 +300,7 @@ class Sheet { updateRundown.push({ deleteDimension: { range: { dimension: 'ROWS', startIndex: titleRow + 2, sheetId: worksheetId } }, }); - // insert the lenght of the rundown + // insert the length of the rundown updateRundown.push({ insertDimension: { inheritFromBefore: false, @@ -340,19 +338,19 @@ class Sheet { } /** - * @description STEP 5 - Downpload the rundown from sheet - * @param {string} id - id of the sheet https://docs.google.com/spreadsheets/d/[[spreadsheetId]]/edit#gid=0 + * @description STEP 5 - Download the rundown from sheet + * @param {string} sheetId - id of the sheet https://docs.google.com/spreadsheets/d/[[spreadsheetId]]/edit#gid=0 * @param {ExcelImportMap} options * @returns {Promise>} * @throws */ - public async pull(id: string, options: ExcelImportMap): Promise> { - const { range } = await this.exist(id, options.worksheet); + public async pull(sheetId: string, options: ExcelImportMap): Promise> { + const { range } = await this.exist(sheetId, options.worksheet); const res: Partial = {}; const googleResponse = await sheets({ version: 'v4', auth: Sheet.client }).spreadsheets.values.get({ - spreadsheetId: id, + spreadsheetId: sheetId, valueRenderOption: 'FORMATTED_VALUE', majorDimension: 'ROWS', range,