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 66a846ecd..739ce9342 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 @@ -15,23 +15,25 @@ 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 { extractSheetId, getPersistedSheetId, persistSheetId } from './gsheetUtils'; import style from './SourcesPanel.module.scss'; interface GSheetSetupProps { onCancel: () => void; onSheetLoaded: (sheetId: string, options: SpreadsheetWorksheetOptions) => void; + closedByUser: boolean; } export default function GSheetSetup(props: GSheetSetupProps) { - const { onCancel, onSheetLoaded } = props; + const { onCancel, onSheetLoaded, closedByUser } = props; const [file, setFile] = useState(null); - const [sheetId, setSheetId] = useState(''); + const [sheetId, setSheetId] = useState(getPersistedSheetId); const [authenticationStatus, setAuthenticationStatus] = useState('not_authenticated'); const [authKey, setAuthKey] = useState(null); - const [loading, setLoading] = useState<'' | 'cancel' | 'connect' | 'authenticate' | 'load-sheet'>(''); const [authLink, setAuthLink] = useState(''); + const [loading, setLoading] = useState<'' | 'cancel' | 'connect' | 'authenticate' | 'load-sheet'>(''); const [authError, setAuthError] = useState(''); const [worksheetError, setWorksheetError] = useState(''); const pollTimeoutRef = useRef(null); @@ -62,6 +64,7 @@ export default function GSheetSetup(props: GSheetSetupProps) { const loadWorksheetOptions = useCallback( async (nextSheetId: string) => { const worksheetOptions = await getWorksheetOptions(nextSheetId); + persistSheetId(nextSheetId); onSheetLoaded(nextSheetId, worksheetOptions); setWorksheetError(''); }, @@ -71,6 +74,7 @@ export default function GSheetSetup(props: GSheetSetupProps) { const pollUntilAuthenticated = useCallback( async (attempts: number = 0) => { clearPollTimeout(); + if (closedByUser) return; try { const result = await verifyAuthenticationStatus(); @@ -82,13 +86,16 @@ export default function GSheetSetup(props: GSheetSetupProps) { pollTimeoutRef.current = window.setTimeout(() => { pollUntilAuthenticated(attempts + 1); }, 2000); - } else { - setLoading(''); + return; // Keep authKey for next poll } + // Polling timed out + setAuthKey(null); + setLoading(''); return; } - if (result.authenticated === 'authenticated') { + if (result.authenticated === 'authenticated' && result.sheetId) { + setLoading('load-sheet'); try { await loadWorksheetOptions(result.sheetId); } catch (error) { @@ -96,13 +103,15 @@ export default function GSheetSetup(props: GSheetSetupProps) { } } + setAuthKey(null); setLoading(''); } catch (error) { setAuthError(maybeAxiosError(error)); + setAuthKey(null); setLoading(''); } }, - [clearPollTimeout, loadWorksheetOptions], + [clearPollTimeout, loadWorksheetOptions, closedByUser], ); /** check if the current session has been authenticated */ @@ -150,7 +159,7 @@ export default function GSheetSetup(props: GSheetSetupProps) { }; /** - * Requests connection to google auth + * Requests a device code from Google. The user can copy it before opening the browser. */ const handleConnect = async () => { if (!file) return; @@ -171,7 +180,7 @@ export default function GSheetSetup(props: GSheetSetupProps) { }; /** - * Open google auth + * Opens the Google verification page and starts polling for completion. */ const handleAuthenticate = () => { setLoading('authenticate'); @@ -180,7 +189,6 @@ export default function GSheetSetup(props: GSheetSetupProps) { clearPollTimeout(); clearAuthFallbackTimeout(); - // open link and schedule a check for when the user focuses again openLink(authLink); authFallbackTimeoutRef.current = window.setTimeout(() => { if (document.hasFocus()) { @@ -269,18 +277,19 @@ export default function GSheetSetup(props: GSheetSetupProps) { )} - Enter ID of sheet to synchronise + Enter ID of sheet to synchronize {worksheetError} { setWorksheetError(''); - setSheetId(event.target.value); + setSheetId(extractSheetId(event.target.value)); }} disabled={isLoading || canAuthenticate} /> +
Paste a Google Sheets URL or the sheet ID from the URL bar.
{isAuthenticated ? ( @@ -304,18 +313,16 @@ export default function GSheetSetup(props: GSheetSetupProps) { ) : ( - Authenticate this Ontime session with Google + Copy the device code, then authenticate with Google {isAuthenticating && Authenticating...} - - {authKey ? authKey : 'Upload files to generate Auth Key'} - - -
Open the browser prompt, complete the code flow, then come back here.
+
Copy the code, then open the browser prompt to complete the flow.
)} 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 53a0ab3d5..9a05388a5 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 @@ -37,12 +37,15 @@ type ActiveSource = kind: 'excel'; worksheetNames: string[]; initialWorksheetMetadata: SpreadsheetWorksheetMetadata | null; + closedByUser: boolean; } | { kind: 'gsheet'; sheetId: string; worksheetNames: string[]; initialWorksheetMetadata: SpreadsheetWorksheetMetadata | null; + title: string; + closedByUser: boolean; }; export default function SourcesPanel() { @@ -73,6 +76,7 @@ export default function SourcesPanel() { kind: 'excel', worksheetNames: worksheetOptions.worksheets, initialWorksheetMetadata: worksheetOptions.metadata, + closedByUser: false, }); setImportFlow('excel'); setHasFile('done'); @@ -106,7 +110,13 @@ export default function SourcesPanel() { }; const cancelImportFlow = () => { - resetFlow(); + if (activeSource && activeSource.kind === 'gsheet') { + // Return to GSheetSetup so the user can change the sheet ID or revoke auth + setActiveSource({ ...activeSource, closedByUser: true }); + setError(''); + } else { + resetFlow(); + } }; const handleFinished = () => { @@ -177,15 +187,22 @@ export default function SourcesPanel() { sheetId, worksheetNames: worksheetOptions.worksheets, initialWorksheetMetadata: worksheetOptions.metadata, + title: worksheetOptions.title ?? '', + closedByUser: false, }); }, []); + const closedByUser = activeSource?.closedByUser ?? false; const isGSheetFlow = importFlow === 'gsheet'; const showInput = importFlow === 'none'; const showCompleted = importFlow === 'finished'; - const showAuth = isGSheetFlow && activeSource === null; - const showImportWorkspace = activeSource !== null; - const importModalTitle = activeSource?.kind === 'excel' ? 'Import spreadsheet' : 'Synchronise with Google Sheet'; + + const showImportWorkspace = activeSource !== null && !closedByUser; + const importModalTitle = (() => { + if (!activeSource) return ''; + if (activeSource.kind === 'excel') return 'Import spreadsheet'; + return activeSource.title ? `Sync: ${activeSource.title}` : 'Synchronize with Google Sheet'; + })(); const sourceKey = (() => { if (!activeSource) return null; if (activeSource.kind === 'excel') return 'excel'; @@ -195,7 +212,7 @@ export default function SourcesPanel() { return ( - Synchronise your rundown with an external source + Synchronize your rundown with an external source {error && {error}} {showInput && (
@@ -233,7 +250,7 @@ export default function SourcesPanel() {
-

Synchronise with Google

+

Synchronize with Google

Connect a Google account once, then load any sheet by ID and keep the import flow inside Ontime. @@ -241,7 +258,7 @@ export default function SourcesPanel() {

Requires Google OAuth client credentials
@@ -257,7 +274,9 @@ export default function SourcesPanel() { )} - {showAuth && } + {isGSheetFlow && ( + + )} { + it('extracts the ID from a full Google Sheets URL', () => { + const url = 'https://docs.google.com/spreadsheets/d/1aBcDeFgHiJkLmNoPqRsTuVwXyZ/edit#gid=0'; + expect(extractSheetId(url)).toBe('1aBcDeFgHiJkLmNoPqRsTuVwXyZ'); + }); + + it('extracts the ID when the URL has no trailing path', () => { + expect(extractSheetId('https://docs.google.com/spreadsheets/d/abc123')).toBe('abc123'); + }); + + it('handles IDs with hyphens and underscores', () => { + const url = 'https://docs.google.com/spreadsheets/d/1a-B_c2/edit'; + expect(extractSheetId(url)).toBe('1a-B_c2'); + }); + + it('returns a raw sheet ID unchanged', () => { + expect(extractSheetId('1aBcDeFgHiJkLmNoPqRsTuVwXyZ')).toBe('1aBcDeFgHiJkLmNoPqRsTuVwXyZ'); + }); + + it('trims whitespace from the input', () => { + expect(extractSheetId(' 1aBcDeFg ')).toBe('1aBcDeFg'); + }); + + it('trims whitespace from a pasted URL', () => { + const url = ' https://docs.google.com/spreadsheets/d/1aBcDeFg/edit '; + expect(extractSheetId(url)).toBe('1aBcDeFg'); + }); + + it('strips query params from a raw ID', () => { + expect(extractSheetId('1aBcDeFgHiJkLmNoPqRsTuVwXyZ?edit=1')).toBe('1aBcDeFgHiJkLmNoPqRsTuVwXyZ'); + }); + + it('strips fragment from a raw ID', () => { + expect(extractSheetId('1aBcDeFg#gid=0')).toBe('1aBcDeFg'); + }); + + it('returns empty string for empty input', () => { + expect(extractSheetId('')).toBe(''); + expect(extractSheetId(' ')).toBe(''); + }); +}); diff --git a/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/gsheetUtils.ts b/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/gsheetUtils.ts new file mode 100644 index 000000000..af042538e --- /dev/null +++ b/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/gsheetUtils.ts @@ -0,0 +1,29 @@ +import { makeStageKey } from '../../../../../common/utils/localStorage'; + +// Matches the document ID segment from a Google Sheets URL: +// https://docs.google.com/spreadsheets/d/{ID}/edit#gid=0 +// ^^^^ captured group +// IDs consist of alphanumeric chars, hyphens, and underscores. +const sheetIdPattern = /\/spreadsheets\/d\/([a-zA-Z0-9_-]+)/; +const lastSheetIdKey = makeStageKey('gsheet:lastSheetId'); + +/** + * Extracts a Google Sheet ID from a full URL, or returns the raw input if it doesn't match. + */ +export function extractSheetId(input: string): string { + const trimmed = input.trim(); + const match = trimmed.match(sheetIdPattern); + if (match) return match[1]; + + // Strip query params (?...) and fragments (#...) in case the user + // pasted a partial URL or an ID with trailing junk like "abc123?edit=1" + return trimmed.split(/[?#]/)[0]; +} + +export function getPersistedSheetId(): string { + return localStorage.getItem(lastSheetIdKey) ?? ''; +} + +export function persistSheetId(sheetId: string) { + localStorage.setItem(lastSheetIdKey, sheetId); +} 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 a2a110219..9ed5db005 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 @@ -16,7 +16,7 @@ export function getWarningText(warning: MappingWarning): string { case 'invalid-name': return 'Column cannot be converted into an Ontime field name'; case 'name-collision': - return 'Column name resolves to a duplicate column'; + return 'Column name resolves to a duplicate Ontime field'; default: return ''; } diff --git a/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/sheet-import/SheetImportMappingPane.tsx b/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/sheet-import/SheetImportMappingPane.tsx index b48831774..c43306abc 100644 --- a/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/sheet-import/SheetImportMappingPane.tsx +++ b/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/sheet-import/SheetImportMappingPane.tsx @@ -50,7 +50,7 @@ export default function SheetImportMappingPane({ return (
- Fields + Column mapping