diff --git a/apps/client/src/common/api/constants.ts b/apps/client/src/common/api/constants.ts index ad1f583cc..aa0551931 100644 --- a/apps/client/src/common/api/constants.ts +++ b/apps/client/src/common/api/constants.ts @@ -11,6 +11,7 @@ export const CUSTOM_VIEWS = ['customViews']; export const PROJECT_DATA = ['project']; export const PROJECT_LIST = ['projectList']; export const PROJECT_RUNDOWNS = ['projectRundowns']; +export const RUNDOWN = ['rundown']; export const CURRENT_RUNDOWN_QUERY_KEY = ['rundown', 'current']; export const getRundownQueryKey = (rundownId: string) => ['rundown', rundownId]; export const RUNTIME = ['runtimeStore']; diff --git a/apps/client/src/common/components/autocomplete-input/AutocompleteInput.tsx b/apps/client/src/common/components/autocomplete-input/AutocompleteInput.tsx index 0c43a51e6..84a6149ea 100644 --- a/apps/client/src/common/components/autocomplete-input/AutocompleteInput.tsx +++ b/apps/client/src/common/components/autocomplete-input/AutocompleteInput.tsx @@ -16,6 +16,7 @@ export interface AutocompleteInputProps extends Omit ReactNode; inputRef?: Ref; + openOnFocus?: boolean; } export default function AutocompleteInput({ @@ -27,6 +28,7 @@ export default function AutocompleteInput({ inputRef, onValueChange, options, + openOnFocus = false, trailingElement, value, variant = 'subtle', @@ -34,6 +36,7 @@ export default function AutocompleteInput({ }: AutocompleteInputProps) { const internalInputRef = useRef(null); const [open, setOpen] = useState(false); + const { onFocus, ...restInputProps } = inputProps; const handleInputRef = (node: HTMLInputElement | null) => { internalInputRef.current = node; @@ -81,6 +84,12 @@ export default function AutocompleteInput({ > { + onFocus?.(event); + if (openOnFocus) { + setOpen(true); + } + }} className={cx([ inputStyles.input, inputStyles[variant], @@ -88,7 +97,7 @@ export default function AutocompleteInput({ fluid && inputStyles.fluid, className, ])} - {...inputProps} + {...restInputProps} /> diff --git a/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/GSheetInfo.tsx b/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/GSheetInfo.tsx deleted file mode 100644 index 197c79a17..000000000 --- a/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/GSheetInfo.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import Info from '../../../../../common/components/info/Info'; -import ExternalLink from '../../../../../common/components/link/external-link/ExternalLink'; - -const googleSheetDocsUrl = 'https://docs.getontime.no/features/import-spreadsheet-gsheet/'; - -export default function GSheetInfo() { - return ( - - Ontime can import data from spreadsheets by:
- importing the spreadsheet file in Ontime
- - synchronising your project with a Google Sheet -
-
- To synchronise with a Google Sheet, you will need to allow Ontime to authenticate with your Google account. - See the docs -
- ); -} diff --git a/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/GSheetSetup.tsx b/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/GSheetSetup.tsx index b6eb4acc5..66a846ecd 100644 --- a/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/GSheetSetup.tsx +++ b/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/GSheetSetup.tsx @@ -1,59 +1,138 @@ -import type { SpreadsheetWorksheetOptions } from 'ontime-types'; -import { ChangeEvent, useEffect, useState } from 'react'; -import { IoCheckmark, IoShieldCheckmarkOutline } from 'react-icons/io5'; +import type { AuthenticationStatus, SpreadsheetWorksheetOptions } from 'ontime-types'; +import { ChangeEvent, useCallback, useEffect, useRef, useState } from 'react'; +import { IoCheckmark, IoCloudDownloadOutline, IoShieldCheckmarkOutline } from 'react-icons/io5'; -import { getWorksheetOptions } from '../../../../../common/api/sheets'; +import { + getWorksheetOptions, + requestConnection, + revokeAuthentication, + verifyAuthenticationStatus, +} from '../../../../../common/api/sheets'; import { maybeAxiosError } from '../../../../../common/api/utils'; import Button from '../../../../../common/components/buttons/Button'; import CopyTag from '../../../../../common/components/copy-tag/CopyTag'; import Input from '../../../../../common/components/input/input/Input'; +import Tag from '../../../../../common/components/tag/Tag'; import { openLink } from '../../../../../common/utils/linkUtils'; import * as Panel from '../../../panel-utils/PanelUtils'; -import useGoogleSheet from './useGoogleSheet'; -import { useSheetStore } from './useSheetStore'; + +import style from './SourcesPanel.module.scss'; interface GSheetSetupProps { onCancel: () => void; - onWorksheetOptionsLoaded?: (options: SpreadsheetWorksheetOptions) => void; + onSheetLoaded: (sheetId: string, options: SpreadsheetWorksheetOptions) => void; } export default function GSheetSetup(props: GSheetSetupProps) { - const { onCancel, onWorksheetOptionsLoaded } = props; + const { onCancel, onSheetLoaded } = props; - const { revoke, connect, verifyAuth } = useGoogleSheet(); const [file, setFile] = useState(null); + const [sheetId, setSheetId] = useState(''); + const [authenticationStatus, setAuthenticationStatus] = useState('not_authenticated'); const [authKey, setAuthKey] = useState(null); - const [loading, setLoading] = useState<'' | 'cancel' | 'connect' | 'authenticate'>(''); + const [loading, setLoading] = useState<'' | 'cancel' | 'connect' | 'authenticate' | 'load-sheet'>(''); const [authLink, setAuthLink] = useState(''); + const [authError, setAuthError] = useState(''); + const [worksheetError, setWorksheetError] = useState(''); + const pollTimeoutRef = useRef(null); + const authFallbackTimeoutRef = useRef(null); + const focusListenerRef = useRef<(() => void) | null>(null); - const sheetId = useSheetStore((state) => state.sheetId); - const setSheetId = useSheetStore((state) => state.setSheetId); - const setWorksheets = useSheetStore((state) => state.setWorksheets); - const patchStepData = useSheetStore((state) => state.patchStepData); - const authenticationStatus = useSheetStore((state) => state.authenticationStatus); - const setAuthenticationStatus = useSheetStore((state) => state.setAuthenticationStatus); - const authenticationError = useSheetStore((state) => state.stepData.authenticate.error); - - /** Check if we are authenticated */ - const getAuthStatus = async () => { - const result = await verifyAuth(); - if (result) { - setAuthenticationStatus(result.authenticated); + const clearPollTimeout = useCallback(() => { + if (pollTimeoutRef.current !== null) { + window.clearTimeout(pollTimeoutRef.current); + pollTimeoutRef.current = null; } - }; + }, []); + + const clearAuthFallbackTimeout = useCallback(() => { + if (authFallbackTimeoutRef.current !== null) { + window.clearTimeout(authFallbackTimeoutRef.current); + authFallbackTimeoutRef.current = null; + } + }, []); + + const clearFocusListener = useCallback(() => { + if (focusListenerRef.current !== null) { + window.removeEventListener('focus', focusListenerRef.current); + focusListenerRef.current = null; + } + }, []); + + const loadWorksheetOptions = useCallback( + async (nextSheetId: string) => { + const worksheetOptions = await getWorksheetOptions(nextSheetId); + onSheetLoaded(nextSheetId, worksheetOptions); + setWorksheetError(''); + }, + [onSheetLoaded], + ); + + const pollUntilAuthenticated = useCallback( + async (attempts: number = 0) => { + clearPollTimeout(); + + try { + const result = await verifyAuthenticationStatus(); + setAuthenticationStatus(result.authenticated); + setSheetId(result.sheetId); + + if (result.authenticated === 'pending') { + if (attempts < 10) { + pollTimeoutRef.current = window.setTimeout(() => { + pollUntilAuthenticated(attempts + 1); + }, 2000); + } else { + setLoading(''); + } + return; + } + + if (result.authenticated === 'authenticated') { + try { + await loadWorksheetOptions(result.sheetId); + } catch (error) { + setWorksheetError(maybeAxiosError(error)); + } + } + + setLoading(''); + } catch (error) { + setAuthError(maybeAxiosError(error)); + setLoading(''); + } + }, + [clearPollTimeout, loadWorksheetOptions], + ); /** check if the current session has been authenticated */ useEffect(() => { - patchStepData({ authenticate: { available: false, error: '' } }); - untilAuthenticated(); - }, []); + setAuthError(''); + pollUntilAuthenticated(); + + return () => { + clearFocusListener(); + clearPollTimeout(); + clearAuthFallbackTimeout(); + }; + }, [clearAuthFallbackTimeout, clearFocusListener, clearPollTimeout, pollUntilAuthenticated]); // user cancels the flow const handleRevoke = async () => { setLoading('cancel'); - await revoke(); - await getAuthStatus(); - setLoading(''); + try { + const result = await revokeAuthentication(); + setAuthenticationStatus(result.authenticated); + setSheetId(''); + setAuthKey(null); + setAuthLink(''); + setAuthError(''); + setWorksheetError(''); + } catch (error) { + setAuthError(maybeAxiosError(error)); + } finally { + setLoading(''); + } }; const handleCancelFlow = () => { @@ -76,68 +155,81 @@ export default function GSheetSetup(props: GSheetSetupProps) { const handleConnect = async () => { if (!file) return; if (!sheetId) return; - patchStepData({ worksheet: { available: false, error: '' } }); - setLoading('connect'); - const result = await connect(file, sheetId); - if (result) { + setAuthError(''); + setWorksheetError(''); + + try { + const result = await requestConnection(file, sheetId); setAuthLink(result.verification_url); setAuthKey(result.user_code); + } catch (error) { + setAuthError(maybeAxiosError(error)); + } finally { + setLoading(''); } - setLoading(''); - }; - - const untilAuthenticated = async (attempts: number = 0) => { - const result = await verifyAuth(); - if (result?.authenticated) { - setAuthenticationStatus(result.authenticated); - if (result.authenticated !== 'pending') { - if (result.authenticated == 'authenticated') { - try { - const worksheetOptions = await getWorksheetOptions(result.sheetId); - setWorksheets(worksheetOptions.worksheets); - onWorksheetOptionsLoaded?.(worksheetOptions); - } catch (error) { - const message = maybeAxiosError(error); - patchStepData({ worksheet: { available: false, error: message } }); - } - } - setLoading(''); - return; - } - } - if (attempts <= 10) { - setTimeout(() => untilAuthenticated(attempts + 1), 2000); - return; - } - setLoading(''); }; /** * Open google auth */ - const handleAuthenticate = async () => { + const handleAuthenticate = () => { setLoading('authenticate'); + setAuthError(''); + clearFocusListener(); + clearPollTimeout(); + clearAuthFallbackTimeout(); // open link and schedule a check for when the user focuses again openLink(authLink); - window.addEventListener( - 'focus', - async () => { - untilAuthenticated(); - }, - { once: true }, - ); + authFallbackTimeoutRef.current = window.setTimeout(() => { + if (document.hasFocus()) { + setLoading(''); + } + }, 1500); + + function authFocusHandler() { + clearAuthFallbackTimeout(); + clearFocusListener(); + pollUntilAuthenticated(); + } + + focusListenerRef.current = authFocusHandler; + window.addEventListener('focus', authFocusHandler, { once: true }); }; - const canConnect = file && sheetId; + const handleLoadSheet = async () => { + if (!sheetId) return; + + setLoading('load-sheet'); + setWorksheetError(''); + + try { + await loadWorksheetOptions(sheetId); + } catch (error) { + setWorksheetError(maybeAxiosError(error)); + } finally { + setLoading(''); + } + }; + + const canConnect = Boolean(file) && Boolean(sheetId); + const canLoadSheet = Boolean(sheetId); const canAuthenticate = Boolean(authKey) && Boolean(authLink); const isLoading = Boolean(loading); const isAuthenticated = authenticationStatus === 'authenticated'; const isAuthenticating = authenticationStatus === 'pending'; + const statusLabel = isAuthenticated ? 'Connected' : isAuthenticating ? 'Waiting for confirmation' : 'Not connected'; + const statusClass = isAuthenticated ? style.statusReady : isAuthenticating ? style.statusPending : style.statusIdle; + const statusVariant = isAuthenticated ? 'default' : 'warning'; + const setupMessage = isAuthenticated + ? 'Load a spreadsheet by its Google Sheet ID.' + : canAuthenticate + ? 'Finish the device verification in your browser, then return here.' + : 'Upload your client secret and enter the sheet ID you want to access.'; return ( - + Sync with Google Sheet (experimental) {isAuthenticated ? ( @@ -148,24 +240,62 @@ export default function GSheetSetup(props: GSheetSetupProps) { )} - - Upload Client Secret provided by Google - {authenticationError} - - - +
+
+

{statusLabel}

+

{setupMessage}

+
+ + {statusLabel} + +
+ {!isAuthenticated && ( + + Upload Client Secret provided by Google + {authError} + +
Use the OAuth client JSON downloaded from your Google Cloud project.
+
+ )} + {isAuthenticated && authError && ( + + {authError} + + )} + Enter ID of sheet to synchronise - {undefined} + {worksheetError} setSheetId(event.target.value)} + onChange={(event) => { + setWorksheetError(''); + setSheetId(event.target.value); + }} disabled={isLoading || canAuthenticate} /> - {!canAuthenticate ? ( - - + {isAuthenticated ? ( + + Load the current spreadsheet configuration + + + + + ) : !canAuthenticate ? ( + + Generate a Google device code + +
Open the browser prompt, complete the code flow, then come back here.
)}
diff --git a/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/SourcesPanel.module.scss b/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/SourcesPanel.module.scss index 84df6e84e..8d293de17 100644 --- a/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/SourcesPanel.module.scss +++ b/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/SourcesPanel.module.scss @@ -1,36 +1,143 @@ -.uploadSection, -.finishSection { - margin-top: 1rem; +.introStack { display: flex; - padding: 3rem 1rem; - align-items: center; - justify-content: center; - background-color: $gray-1350; - border: 1px solid $white-10; - border-radius: $component-border-radius-md; -} - -.uploadSection { - flex-direction: row; - gap: 2rem; -} - -.finishSection { - font-size: 1.5rem; - text-align: center; flex-direction: column; gap: 1rem; +} - .error { - color: $red-500; - } - .success { - color: $green-500; - } +.setupLead, +.finishTitle { + margin: 0; + color: $ui-white; + font-size: 1rem; + font-weight: 600; +} + +.setupBody, +.finishDescription, +.sourceDescription { + margin: 0; + color: $gray-400; + line-height: 1.5; +} + +.sourceGrid, +.finishSection { + margin-top: 1rem; +} + +.sourceGrid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1rem; +} + +.sourceCard, +.finishSection, +.setupBlock { + border: 1px solid $white-10; + border-radius: $component-border-radius-md; + background: $gray-1350; +} + +.sourceCard { + display: flex; + flex-direction: column; + gap: 1rem; + min-height: 14rem; + padding: 1rem; +} + +.sourceHeader, +.setupIntroText { + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.sourceTitle { + margin: 0; + font-size: 1rem; + color: $ui-white; + font-weight: 600; +} + +.sourceMeta, +.setupHint { + color: $gray-500; + font-size: calc(1rem - 2px); +} + +.finishSection { + display: flex; + padding: 2rem 1rem; + align-items: center; + justify-content: center; + text-align: center; + flex-direction: column; + gap: 0.75rem; +} + +.finishBadge { + padding: 0.35rem 0.7rem; + font-size: calc(1rem - 3px); + font-weight: 600; + letter-spacing: 0.5px; + text-transform: uppercase; + border-radius: 2px; + color: $blue-300; + background-color: rgb(87 153 255 / 12%); + border: 1px solid rgb(87 153 255 / 22%); +} + +.setupShell { + margin-top: 1rem; + border-top: 1px solid $white-10; + padding-top: 1rem; +} + +.setupIntro { + padding: 0 2rem; + display: flex; + justify-content: space-between; + gap: 1rem; + align-items: flex-start; +} + +.setupBlock { + list-style: none; + margin: 0 2rem; + padding: 1rem; +} + +.setupActions { + margin-top: 0.75rem; +} + +.statusIdle { + color: $gray-400; + background-color: $white-3; +} + +.statusPending { + color: $orange-400; + background-color: rgb(255 162 0 / 10%); +} + +.statusReady { + color: $green-400; + background-color: rgb(57 198 120 / 10%); } @media (max-width: $medium-screen) { - .uploadSection { + .sourceGrid { + grid-template-columns: 1fr; + } + + .setupIntro { flex-direction: column; } + + .setupBlock { + margin: 0; + } } diff --git a/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/SourcesPanel.tsx b/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/SourcesPanel.tsx index 367867b5c..53a0ab3d5 100644 --- a/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/SourcesPanel.tsx +++ b/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/SourcesPanel.tsx @@ -1,4 +1,8 @@ -import type { SpreadsheetPreviewResponse, SpreadsheetWorksheetMetadata } from 'ontime-types'; +import type { + SpreadsheetPreviewResponse, + SpreadsheetWorksheetMetadata, + SpreadsheetWorksheetOptions, +} from 'ontime-types'; import { getErrorMessage, ImportMap } from 'ontime-utils'; import { ChangeEvent, useCallback, useRef, useState } from 'react'; import { IoCloudOutline, IoDownloadOutline } from 'react-icons/io5'; @@ -10,40 +14,45 @@ import { } from '../../../../../common/api/excel'; import { getWorksheetMetadata as getGoogleWorksheetMetadata, - getWorksheetOptions, previewRundown as previewGoogleSheet, uploadRundown, } from '../../../../../common/api/sheets'; -import { maybeAxiosError } from '../../../../../common/api/utils'; import Button from '../../../../../common/components/buttons/Button'; -import * as Editor from '../../../../../common/components/editor-utils/EditorUtils'; +import Info from '../../../../../common/components/info/Info'; +import ExternalLink from '../../../../../common/components/link/external-link/ExternalLink'; import Modal from '../../../../../common/components/modal/Modal'; import useRundown from '../../../../../common/hooks-query/useRundown'; import { validateExcelImport } from '../../../../../common/utils/uploadUtils'; import * as Panel from '../../../panel-utils/PanelUtils'; -import GSheetInfo from './GSheetInfo'; import GSheetSetup from './GSheetSetup'; import SheetImportEditor from './sheet-import/SheetImportEditor'; -import useGoogleSheet from './useGoogleSheet'; -import { useSheetStore } from './useSheetStore'; +import useSpreadsheetImport from './useSpreadsheetImport'; import style from './SourcesPanel.module.scss'; +const googleSheetDocsUrl = 'https://docs.getontime.no/features/import-spreadsheet-gsheet/'; + +type ActiveSource = + | { + kind: 'excel'; + worksheetNames: string[]; + initialWorksheetMetadata: SpreadsheetWorksheetMetadata | null; + } + | { + kind: 'gsheet'; + sheetId: string; + worksheetNames: string[]; + initialWorksheetMetadata: SpreadsheetWorksheetMetadata | null; + }; + export default function SourcesPanel() { const [importFlow, setImportFlow] = useState<'none' | 'excel' | 'gsheet' | 'finished'>('none'); const [error, setError] = useState(''); const [hasFile, setHasFile] = useState<'none' | 'loading' | 'done'>('none'); - const [initialWorksheetMetadata, setInitialWorksheetMetadata] = useState(null); + const [activeSource, setActiveSource] = useState(null); const { data: currentRundown } = useRundown(); - const { importRundown, verifyAuth } = useGoogleSheet(); - - const setWorksheets = useSheetStore((state) => state.setWorksheets); - const worksheetNames = useSheetStore((state) => state.worksheetNames); - const authenticationStatus = useSheetStore((state) => state.authenticationStatus); - const setAuthenticationStatus = useSheetStore((state) => state.setAuthenticationStatus); - const setSheetId = useSheetStore((state) => state.setSheetId); - const sheetId = useSheetStore((state) => state.sheetId); + const { importRundown } = useSpreadsheetImport(); const fileInputRef = useRef(null); @@ -51,7 +60,7 @@ export default function SourcesPanel() { const fileToUpload = event.target.files?.[0]; if (!fileToUpload) { - setWorksheets(null); + setActiveSource(null); setHasFile('none'); return; } @@ -60,15 +69,17 @@ export default function SourcesPanel() { setError(''); validateExcelImport(fileToUpload); const worksheetOptions = await uploadExcel(fileToUpload); - setWorksheets(worksheetOptions.worksheets); - setInitialWorksheetMetadata(worksheetOptions.metadata); + setActiveSource({ + kind: 'excel', + worksheetNames: worksheetOptions.worksheets, + initialWorksheetMetadata: worksheetOptions.metadata, + }); setImportFlow('excel'); setHasFile('done'); } catch (error) { const errorMessage = getErrorMessage(error); setError(`Error uploading file: ${errorMessage}`); - setWorksheets(null); - setInitialWorksheetMetadata(null); + setActiveSource(null); setHasFile('none'); } }; @@ -78,34 +89,15 @@ export default function SourcesPanel() { }; const resetFlow = () => { - // we purposely omit clearing the authentication status setImportFlow('none'); setHasFile('none'); - setWorksheets(null); + setActiveSource(null); setError(''); - setSheetId(null); - setInitialWorksheetMetadata(null); }; - const openGSheetFlow = async () => { + const openGSheetFlow = () => { setError(''); - setInitialWorksheetMetadata(null); - const result = await verifyAuth(); - if (result) { - setAuthenticationStatus(result.authenticated); - setSheetId(result.sheetId); - if (result.authenticated === 'authenticated' && result.sheetId) { - try { - const worksheetOptions = await getWorksheetOptions(result.sheetId); - setWorksheets(worksheetOptions.worksheets); - setInitialWorksheetMetadata(worksheetOptions.metadata); - } catch (error) { - const message = maybeAxiosError(error); - setError(`Error getting worksheets: ${message}`); - setInitialWorksheetMetadata(null); - } - } - } + setActiveSource(null); setImportFlow('gsheet'); }; @@ -120,7 +112,7 @@ export default function SourcesPanel() { const handleFinished = () => { setImportFlow('finished'); setHasFile('none'); - setWorksheets(null); + setActiveSource(null); setError(''); }; @@ -143,35 +135,62 @@ export default function SourcesPanel() { }; const loadWorksheetMetadata = useCallback( - (worksheet: string) => - importFlow === 'excel' + (worksheet: string) => { + if (!activeSource) { + throw new Error('No spreadsheet source loaded'); + } + + return activeSource.kind === 'excel' ? getExcelWorksheetMetadata(worksheet) - : getGoogleWorksheetMetadata(sheetId as string, worksheet), - [importFlow, sheetId], + : getGoogleWorksheetMetadata(activeSource.sheetId, worksheet); + }, + [activeSource], ); const previewImport = useCallback( - (importMap: ImportMap): Promise => - importFlow === 'excel' ? importExcelPreview(importMap) : previewGoogleSheet(sheetId as string, importMap), - [importFlow, sheetId], + (importMap: ImportMap): Promise => { + if (!activeSource) { + throw new Error('No spreadsheet source loaded'); + } + + return activeSource.kind === 'excel' + ? importExcelPreview(importMap) + : previewGoogleSheet(activeSource.sheetId, importMap); + }, + [activeSource], ); const exportToGoogleSheet = useCallback( - (importMap: ImportMap): Promise => uploadRundown(sheetId as string, importMap), - [sheetId], + (importMap: ImportMap): Promise => { + if (!activeSource || activeSource.kind !== 'gsheet') { + throw new Error('Google Sheet source not available'); + } + + return uploadRundown(activeSource.sheetId, importMap); + }, + [activeSource], ); - const isExcelFlow = importFlow === 'excel'; + const handleSheetLoaded = useCallback((sheetId: string, worksheetOptions: SpreadsheetWorksheetOptions) => { + setActiveSource({ + kind: 'gsheet', + sheetId, + worksheetNames: worksheetOptions.worksheets, + initialWorksheetMetadata: worksheetOptions.metadata, + }); + }, []); + const isGSheetFlow = importFlow === 'gsheet'; - const isAuthenticated = authenticationStatus === 'authenticated'; const showInput = importFlow === 'none'; const showCompleted = importFlow === 'finished'; - const showAuth = isGSheetFlow && (!isAuthenticated || !worksheetNames?.length); - const showImportWorkspace = - (isExcelFlow && hasFile === 'done' && Boolean(worksheetNames?.length)) || - (isGSheetFlow && isAuthenticated && Boolean(sheetId) && Boolean(worksheetNames?.length)); - const importModalTitle = isExcelFlow ? 'Import spreadsheet' : 'Synchronise with Google Sheet'; - const sourceKey = isExcelFlow ? 'excel' : sheetId ? `gsheet:${sheetId}` : null; + const showAuth = isGSheetFlow && activeSource === null; + const showImportWorkspace = activeSource !== null; + const importModalTitle = activeSource?.kind === 'excel' ? 'Import spreadsheet' : 'Synchronise with Google Sheet'; + const sourceKey = (() => { + if (!activeSource) return null; + if (activeSource.kind === 'excel') return 'excel'; + return `gsheet:${activeSource.sheetId}`; + })(); return ( @@ -179,8 +198,17 @@ export default function SourcesPanel() { Synchronise your rundown with an external source {error && {error}} {showInput && ( - <> - +
+ + Choose between a quick file import or a live Google Sheet connection. + + Google Sheets sync needs a client secret and a one-time device authentication before you can load a + sheet by ID. + + + Read setup guide + + -
-
- - Accepts .xlsx files -
- -
- - Start authentication process -
+
- +
)} {showCompleted && (
- {error ? ( - - Import failed - - ) : ( - - Import successful - - )} -
)} - {showAuth && ( - { - setWorksheets(worksheetOptions.worksheets); - setInitialWorksheetMetadata(worksheetOptions.metadata); - }} - /> - )} + {showAuth && } } /> diff --git a/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/sheet-import/MappingFieldRow.tsx b/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/sheet-import/MappingFieldRow.tsx index b68dd6ec8..a2a110219 100644 --- a/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/sheet-import/MappingFieldRow.tsx +++ b/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/sheet-import/MappingFieldRow.tsx @@ -51,6 +51,7 @@ export default function MappingFieldRow({ className={cx([style.columnInput, warning && style.columnInputWarn])} maxLength={50} options={options} + openOnFocus trailingElement={(option) => (assigned.has(option) ? : null)} placeholder='Spreadsheet column' disabled={disabled} diff --git a/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/sheet-import/__tests__/spreadsheetImportUtils.test.ts b/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/sheet-import/__tests__/spreadsheetImportUtils.test.ts index 6f58e9d36..a4d784bc6 100644 --- a/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/sheet-import/__tests__/spreadsheetImportUtils.test.ts +++ b/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/sheet-import/__tests__/spreadsheetImportUtils.test.ts @@ -49,14 +49,14 @@ describe('deriveHeaderOptionsState()', () => { const values = makeValues({}); const { sampleHeaders } = deriveHeaderOptionsState(values, ['Title', 'Title', '', ' ', 'Cue']); - expect(sampleHeaders).toEqual(['title', 'cue']); + expect(sampleHeaders).toEqual(['Title', 'Cue']); }); it('deduplicates headers case-insensitively', () => { const values = makeValues({}); const { sampleHeaders } = deriveHeaderOptionsState(values, ['Title', 'TITLE', 'title', 'Artist']); - expect(sampleHeaders).toEqual(['title', 'artist']); + expect(sampleHeaders).toEqual(['Title', 'Artist']); }); it('matches headers case-insensitively', () => { diff --git a/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/sheet-import/importMapUtils.ts b/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/sheet-import/importMapUtils.ts index 6b80b9cdb..74cf3864e 100644 --- a/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/sheet-import/importMapUtils.ts +++ b/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/sheet-import/importMapUtils.ts @@ -1,6 +1,7 @@ import type { ImportMap } from 'ontime-utils'; import { makeStageKey } from '../../../../../../common/utils/localStorage'; +import { normaliseColumnName } from './spreadsheetImportUtils'; export type MappingWarning = { kind: 'duplicate' | 'missing' | 'invalid-name' | 'name-collision'; @@ -149,10 +150,6 @@ export function getPersistedImportState(sourceKey: string): ImportFormValues { } } -function normaliseColumn(value: string | undefined): string { - return value?.trim().toLowerCase() ?? ''; -} - /** * Validates import mappings and generates warnings for duplicate or missing spreadsheet columns. */ @@ -161,7 +158,7 @@ export function getImportWarnings( detectedSpreadsheetColumns: string[], existingCustomFieldLabels: string[] = [], ): Record { - const normalisedHeaders = new Set(detectedSpreadsheetColumns.map(normaliseColumn).filter(Boolean)); + const normalisedHeaders = new Set(detectedSpreadsheetColumns.map(normaliseColumnName).filter(Boolean)); const builtInLabels = new Set(builtInFieldDefs.map((def) => def.label.toLowerCase())); const existingLabels = new Set(existingCustomFieldLabels.map((label) => label.trim().toLowerCase()).filter(Boolean)); const seenColumns = new Set(); @@ -173,7 +170,7 @@ export function getImportWarnings( const field = values.builtIn[i]; if (!field.enabled) continue; - const normalised = normaliseColumn(field.header); + const normalised = normaliseColumnName(field.header); if (!normalised) continue; const key = `builtIn.${i}.header`; @@ -189,7 +186,7 @@ export function getImportWarnings( // 2. check custom fields values.custom.forEach(({ importName }, index) => { - const normalised = normaliseColumn(importName); + const normalised = normaliseColumnName(importName); if (!normalised) return; const key = `custom.${index}.importName`; diff --git a/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/sheet-import/preview/PreviewTable.tsx b/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/sheet-import/preview/PreviewTable.tsx index 722f79361..7945b9db1 100644 --- a/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/sheet-import/preview/PreviewTable.tsx +++ b/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/sheet-import/preview/PreviewTable.tsx @@ -1,13 +1,13 @@ -import type { CustomField, CustomFieldKey } from 'ontime-types'; +import type { CustomField, CustomFieldKey, SpreadsheetPreviewResponse } from 'ontime-types'; import { isOntimeEvent, isOntimeGroup, isOntimeMilestone } from 'ontime-types'; import { useMemo } from 'react'; -import { type PreviewState, getCellValue } from './previewTableUtils'; +import { getCellValue } from './previewTableUtils'; import style from './PreviewTable.module.scss'; interface PreviewTableProps { - preview: PreviewState | null; + preview: SpreadsheetPreviewResponse | null; columnLabels: string[]; isLoadingMetadata: boolean; worksheetHeaders: string[]; diff --git a/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/sheet-import/preview/previewTableUtils.ts b/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/sheet-import/preview/previewTableUtils.ts index 81e86ddc3..9af834e4f 100644 --- a/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/sheet-import/preview/previewTableUtils.ts +++ b/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/sheet-import/preview/previewTableUtils.ts @@ -1,16 +1,10 @@ -import type { CustomField, CustomFieldKey, CustomFields, OntimeEntry, Rundown, RundownSummary } from 'ontime-types'; +import type { CustomField, CustomFieldKey, OntimeEntry } from 'ontime-types'; import { isOntimeEvent, isOntimeMilestone } from 'ontime-types'; import type { ImportMap } from 'ontime-utils'; import { millisToString } from 'ontime-utils'; import { builtInFieldDefs } from '../importMapUtils'; -export type PreviewState = { - rundown: Rundown; - customFields: CustomFields; - summary: RundownSummary; -}; - type BuiltInImportKey = keyof Omit; const importKeyByLabel = new Map(builtInFieldDefs.map((def) => [def.label, def.importKey])); diff --git a/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/sheet-import/spreadsheetImportUtils.ts b/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/sheet-import/spreadsheetImportUtils.ts index b87c258b7..5186c6473 100644 --- a/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/sheet-import/spreadsheetImportUtils.ts +++ b/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/sheet-import/spreadsheetImportUtils.ts @@ -1,7 +1,7 @@ import type { ImportFormValues } from './importMapUtils'; -export function normaliseHeaderName(value: string): string { - return value.trim().toLowerCase(); +export function normaliseColumnName(value: string | undefined): string { + return value?.trim().toLowerCase() ?? ''; } /** @@ -11,26 +11,37 @@ export function normaliseHeaderName(value: string): string { * offer in the UI and the subset already assigned to any field. */ export function deriveHeaderOptionsState(values: ImportFormValues, headers: string[]) { - const sampleHeaders = [...new Set(headers.map(normaliseHeaderName).filter(Boolean))]; + const seenHeaders = new Set(); + const sampleHeaders: string[] = []; + + for (const header of headers) { + const normalized = normaliseColumnName(header); + if (!normalized || seenHeaders.has(normalized)) { + continue; + } + + seenHeaders.add(normalized); + sampleHeaders.push(header.trim()); + } const assignedColumns = new Set(); for (const field of values.builtIn) { if (!field.enabled) continue; - const normalized = normaliseHeaderName(field.header); + const normalized = normaliseColumnName(field.header); if (normalized) { assignedColumns.add(normalized); } } for (const { importName } of values.custom) { - const normalized = normaliseHeaderName(importName); + const normalized = normaliseColumnName(importName); if (normalized) { assignedColumns.add(normalized); } } - const assignedHeaders = new Set(sampleHeaders.filter((header) => assignedColumns.has(normaliseHeaderName(header)))); + const assignedHeaders = new Set(sampleHeaders.filter((header) => assignedColumns.has(normaliseColumnName(header)))); return { sampleHeaders, assignedHeaders }; } diff --git a/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/sheet-import/useSheetImportForm.ts b/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/sheet-import/useSheetImportForm.ts index 17de4e13d..cd4a1b1c5 100644 --- a/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/sheet-import/useSheetImportForm.ts +++ b/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/sheet-import/useSheetImportForm.ts @@ -16,14 +16,13 @@ import { getResolvedCustomFields, persistImportState, } from './importMapUtils'; -import type { PreviewState } from './preview/previewTableUtils'; import { deriveHeaderOptionsState } from './spreadsheetImportUtils'; type ImportAction = | { type: 'startPreview' } | { type: 'startApply' } | { type: 'startExport' } - | { type: 'previewSuccess'; preview: PreviewState } + | { type: 'previewSuccess'; preview: SpreadsheetPreviewResponse } | { type: 'applySuccess' } | { type: 'exportSuccess' } | { type: 'clearPreview'; error?: string } @@ -33,7 +32,7 @@ type ImportAction = type ImportState = { loading: '' | 'preview' | 'apply' | 'export'; error: string; - preview: PreviewState | null; + preview: SpreadsheetPreviewResponse | null; }; const initialImportState: ImportState = { @@ -106,7 +105,7 @@ interface UseSheetImportFormProps { initialMetadata: SpreadsheetWorksheetMetadata | null; loadMetadata: (worksheet: string) => Promise; previewImport: (importMap: ReturnType) => Promise; - onApply: (preview: PreviewState) => Promise; + onApply: (preview: SpreadsheetPreviewResponse) => Promise; onExport?: (importMap: ReturnType) => Promise; } @@ -128,6 +127,7 @@ export function useSheetImportForm({ const { control, handleSubmit, + getValues, reset, setValue, watch, @@ -163,10 +163,7 @@ export function useSheetImportForm({ const metadataError = worksheetMetadataQuery.error ? maybeAxiosError(worksheetMetadataQuery.error) : ''; // --- Derived state --- - const { sampleHeaders, assignedHeaders } = useMemo( - () => deriveHeaderOptionsState(values, headers), - [values, headers], - ); + const { sampleHeaders, assignedHeaders } = deriveHeaderOptionsState(values, headers); const columnLabels = buildColumnLabels(values); const [state, dispatch] = useReducer(importReducer, initialImportState); @@ -176,12 +173,13 @@ export function useSheetImportForm({ ); const warnings = getImportWarnings(values, headers, existingCustomFieldLabels); const warningCount = Object.values(warnings).filter(Boolean).length; + const previewRef = useRef(null); // Rehydrate the form from persisted/default state whenever the source context changes. useEffect(() => { reset(initialFormValues); dispatch({ type: 'reset' }); - }, [initialMetadata, initialFormValues, reset, sourceKey]); + }, [initialFormValues, reset]); // Keep the worksheet selection valid if the available worksheets change underneath the form. useEffect(() => { @@ -190,15 +188,19 @@ export function useSheetImportForm({ setValue('worksheet', worksheetNames[0], { shouldDirty: true, shouldValidate: true }); }, [setValue, values.worksheet, worksheetNames]); - // Clear preview on any form change. + useEffect(() => { + previewRef.current = state.preview; + }, [state.preview]); + + // Clear preview on any form change without re-subscribing on preview updates. useEffect(() => { const sub = watch(() => { - if (state.preview) { - dispatch({ type: 'clearPreview' }); - } + if (!previewRef.current) return; + previewRef.current = null; + dispatch({ type: 'clearPreview' }); }); return () => sub.unsubscribe(); - }, [watch, state.preview]); + }, [watch]); // Race condition guard for async preview requests. const requestIdRef = useRef(0); @@ -229,12 +231,12 @@ export function useSheetImportForm({ try { dispatch({ type: 'startApply' }); await onApply(state.preview); - persistImportState(sourceKey, values); + persistImportState(sourceKey, getValues()); dispatch({ type: 'applySuccess' }); } catch (error) { dispatch({ type: 'failure', error: maybeAxiosError(error) }); } - }, [onApply, sourceKey, state.preview, values]); + }, [getValues, onApply, sourceKey, state.preview]); const handleExport = useCallback( async (formValues: ImportFormValues) => { diff --git a/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/useGoogleSheet.ts b/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/useGoogleSheet.ts deleted file mode 100644 index 937101d1c..000000000 --- a/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/useGoogleSheet.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { useQueryClient } from '@tanstack/react-query'; -import { AuthenticationStatus, CustomFields, ProjectRundowns, ProjectRundownsList } from 'ontime-types'; -import { ImportMap } from 'ontime-utils'; - -import { - CURRENT_RUNDOWN_QUERY_KEY, - CUSTOM_FIELDS, - PROJECT_RUNDOWNS, - getRundownQueryKey, -} from '../../../../../common/api/constants'; -import { patchData } from '../../../../../common/api/db'; -import { requestConnection, revokeAuthentication, verifyAuthenticationStatus } from '../../../../../common/api/sheets'; -import { maybeAxiosError } from '../../../../../common/api/utils'; -import { useSheetStore } from './useSheetStore'; - -export default function useGoogleSheet() { - const queryClient = useQueryClient(); - const patchStepData = useSheetStore((state) => state.patchStepData); - - /** whether the current session has been authenticated */ - const verifyAuth = async (): Promise<{ authenticated: AuthenticationStatus; sheetId: string } | void> => { - try { - return await verifyAuthenticationStatus(); - } catch (error) { - patchStepData({ authenticate: { available: false, error: maybeAxiosError(error) } }); - } - }; - - /** requests connection to a google sheet */ - const connect = async ( - file: File, - sheetId: string, - ): Promise<{ verification_url: string; user_code: string } | void> => { - try { - return await requestConnection(file, sheetId); - } catch (error) { - patchStepData({ authenticate: { available: false, error: maybeAxiosError(error) } }); - } - }; - - /** requests the revoking of an existing authenticated session */ - const revoke = async (): Promise<{ authenticated: AuthenticationStatus } | void> => { - try { - return await revokeAuthentication(); - } catch (error) { - patchStepData({ authenticate: { available: false, error: maybeAxiosError(error) } }); - } - }; - - /** applies rundown and customFields to current project */ - const importRundown = async (rundowns: ProjectRundowns, customFields: CustomFields) => { - try { - await patchData({ rundowns, customFields }); - // we are unable to optimistically set the rundown since we need - // it to be normalised - const loadedRundownId = queryClient.getQueryData(PROJECT_RUNDOWNS)?.loaded; - const rundownQueryKey = loadedRundownId ? getRundownQueryKey(loadedRundownId) : CURRENT_RUNDOWN_QUERY_KEY; - await queryClient.invalidateQueries({ queryKey: rundownQueryKey }); - await queryClient.invalidateQueries({ queryKey: CUSTOM_FIELDS }); - } catch (error) { - patchStepData({ pullPush: { available: true, error: maybeAxiosError(error) } }); - throw error; - } - }; - - return { - connect, - revoke, - verifyAuth, - importRundown, - }; -} diff --git a/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/useSheetStore.ts b/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/useSheetStore.ts deleted file mode 100644 index a18486b71..000000000 --- a/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/useSheetStore.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { AuthenticationStatus } from 'ontime-types'; -import { create } from 'zustand'; - -type SheetStore = { - stepData: typeof initialStepData; - patchStepData: (patch: Partial) => void; - setWorksheets: (worksheetNames: string[] | null) => void; - worksheetNames: string[] | null; - - //gSheet - sheetId: string | null; - setSheetId: (sheetId: string | null) => void; - authenticationStatus: AuthenticationStatus; - setAuthenticationStatus: (status: AuthenticationStatus) => void; - - reset: () => void; -}; - -const initialStepData = { - authenticate: { available: false, error: '' }, - sheetId: { available: false, error: '' }, - worksheet: { available: false, error: '' }, - pullPush: { available: false, error: '' }, -}; - -const initialState = { - stepData: initialStepData, - worksheetNames: null, - sheetId: null, - authenticationStatus: 'not_authenticated' as AuthenticationStatus, -}; - -export const useSheetStore = create((set, get) => ({ - ...initialState, - - patchStepData: (patch: Partial) => { - const stepData = get().stepData; - set({ stepData: { ...stepData, ...patch } }); - }, - - setWorksheets: (worksheetNames: string[] | null) => set({ worksheetNames }), - - setSheetId: (sheetId: string | null) => set({ sheetId }), - - setAuthenticationStatus: (status: AuthenticationStatus) => set({ authenticationStatus: status }), - - reset: () => set(initialState), -})); diff --git a/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/useSpreadsheetImport.ts b/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/useSpreadsheetImport.ts new file mode 100644 index 000000000..01819e212 --- /dev/null +++ b/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/useSpreadsheetImport.ts @@ -0,0 +1,30 @@ +import { useQueryClient } from '@tanstack/react-query'; +import { CustomFields, ProjectRundowns } from 'ontime-types'; +import { useCallback } from 'react'; + +import { CUSTOM_FIELDS, RUNDOWN } from '../../../../../common/api/constants'; +import { patchData } from '../../../../../common/api/db'; + +export default function useSpreadsheetImport() { + const queryClient = useQueryClient(); + + /** applies rundown and customFields to current project */ + const importRundown = useCallback( + async (rundowns: ProjectRundowns, customFields: CustomFields) => { + await patchData({ rundowns, customFields }); + // we are unable to optimistically set the rundown since we need + // it to be normalised + await queryClient.invalidateQueries({ + queryKey: RUNDOWN, + }); + await queryClient.invalidateQueries({ + queryKey: CUSTOM_FIELDS, + }); + }, + [queryClient], + ); + + return { + importRundown, + }; +} diff --git a/apps/server/src/api-data/sheets/sheets.service.ts b/apps/server/src/api-data/sheets/sheets.service.ts index 14dda081d..6fdb52d26 100644 --- a/apps/server/src/api-data/sheets/sheets.service.ts +++ b/apps/server/src/api-data/sheets/sheets.service.ts @@ -215,7 +215,7 @@ function verifyConnection( export function hasAuth(): { authenticated: AuthenticationStatus; sheetId: string } { if (!currentSheetId) { - throw new Error('No sheet ID'); + return { authenticated: 'not_authenticated', sheetId: '' }; } if (cleanupTimeout) { return { authenticated: 'pending', sheetId: currentSheetId }; diff --git a/e2e/tests/features/301-spreadsheet-import.spec.ts b/e2e/tests/features/301-spreadsheet-import.spec.ts index cc860f7af..d3ca9bdd8 100644 --- a/e2e/tests/features/301-spreadsheet-import.spec.ts +++ b/e2e/tests/features/301-spreadsheet-import.spec.ts @@ -32,8 +32,9 @@ test('imports spreadsheet and applies imported rundown to editor', async ({ page // apply import await page.getByRole('button', { name: 'Preview import' }).click(); await page.getByRole('button', { name: 'Apply import' }).click(); - await expect(page.getByText('Import successful')).toBeVisible(); - await page.getByRole('button', { name: 'Return' }).click(); + await expect(page.getByText('Import complete')).toBeVisible(); + await expect(page.getByText('Spreadsheet data applied.')).toBeVisible(); + await page.getByRole('button', { name: 'Reset flow' }).click(); // verify the data in the rundown await page.getByRole('button', { name: 'Close settings' }).scrollIntoViewIfNeeded();